Files
Viz/public/app.js
T

2844 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* VIZ 2.0 Audio Visualizer Studio — Core Application & Rendering Engine
* Next-Gen Multi-Shape, Neon Glow & Modular Layer Renderer
*/
(function () {
'use strict';
// --- Configuration State ---
const state = {
audioLoaded: false,
audioBuffer: null,
audioFile: null,
imageFile: null,
coverImage: null,
audioSource: null,
audioCtx: null,
analyser: null,
gainNode: null,
isPlaying: false,
startTime: 0,
pauseOffset: 0,
duration: 0,
loop: true,
aspectRatio: '16:9',
width: 1920,
height: 1080,
// Beat Detection & Dynamics
currentPulse: 1.0,
currentRotation: 0,
shakeX: 0,
shakeY: 0,
shakeZoom: 1.0,
beatEnergy: 0,
gridOffset: 0,
// Particles Pool
particles: [],
// Export Controller
isExporting: false,
exportAbortController: null,
// Active Settings
config: Object.assign({}, (typeof window !== 'undefined' && window.VIZ_PRESETS && window.VIZ_PRESETS.trap_nation) || (typeof VIZ_PRESETS !== 'undefined' && VIZ_PRESETS.trap_nation) || {})
};
// DOM Elements
const canvas = document.getElementById('vizCanvas');
const ctx = canvas.getContext('2d', { alpha: true });
const canvasWrapper = document.getElementById('canvasWrapper');
const audioDropzone = document.getElementById('audioDropzone');
const imageDropzone = document.getElementById('imageDropzone');
const audioFileInput = document.getElementById('audioFileInput');
const imageFileInput = document.getElementById('imageFileInput');
const audioFileBadge = document.getElementById('audioFileBadge');
const imageFileBadge = document.getElementById('imageFileBadge');
const coverUploadContainer = document.getElementById('coverUploadContainer');
const btnPlayPause = document.getElementById('btnPlayPause');
const iconPlay = document.getElementById('iconPlay');
const iconPause = document.getElementById('iconPause');
const btnRewind = document.getElementById('btnRewind');
const btnForward = document.getElementById('btnForward');
const btnLoop = document.getElementById('btnLoop');
const seekScrubber = document.getElementById('seekScrubber');
const currentTimeEl = document.getElementById('currentTime');
const durationTimeEl = document.getElementById('durationTime');
const volumeSlider = document.getElementById('volumeSlider');
const aspectBtns = document.querySelectorAll('.aspect-btn');
const toggleSafeZone = document.getElementById('toggleSafeZone');
const safeZoneOverlay = document.getElementById('safeZoneOverlay');
const toggleAlphaGrid = document.getElementById('toggleAlphaGrid');
const quickPresetSelect = document.getElementById('quickPresetSelect');
const tabBtns = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
const btnStartExport = document.getElementById('btnStartExport');
const headerExportBtn = document.getElementById('headerExportBtn');
const btnCancelExport = document.getElementById('btnCancelExport');
const exportProgressCard = document.getElementById('exportProgressCard');
const exportStatusText = document.getElementById('exportStatusText');
const exportPercent = document.getElementById('exportPercent');
const exportProgressBarFill = document.getElementById('exportProgressBarFill');
const exportFpsLive = document.getElementById('exportFpsLive');
const exportEta = document.getElementById('exportEta');
const btnSavePreset = document.getElementById('btnSavePreset');
const btnExportPresetJson = document.getElementById('btnExportPresetJson');
const btnImportPresetJson = document.getElementById('btnImportPresetJson');
const importPresetInput = document.getElementById('importPresetInput');
// Generate Default Synthwave Cover
function generateDefaultCover() {
const off = document.createElement('canvas');
off.width = 600;
off.height = 600;
const c = off.getContext('2d');
const grad = c.createLinearGradient(0, 0, 600, 600);
grad.addColorStop(0, '#0d1322');
grad.addColorStop(0.5, '#1e0d2b');
grad.addColorStop(1, '#080a12');
c.fillStyle = grad;
c.fillRect(0, 0, 600, 600);
const sunGrad = c.createRadialGradient(300, 240, 10, 300, 240, 190);
sunGrad.addColorStop(0, '#ff007f');
sunGrad.addColorStop(0.6, '#ffaa00');
sunGrad.addColorStop(1, 'transparent');
c.fillStyle = sunGrad;
c.beginPath();
c.arc(300, 240, 190, 0, Math.PI * 2);
c.fill();
c.strokeStyle = 'rgba(0, 240, 255, 0.35)';
c.lineWidth = 2;
for (let i = 0; i <= 600; i += 40) {
c.beginPath();
c.moveTo(i, 360);
c.lineTo(300 + (i - 300) * 3, 600);
c.stroke();
}
for (let y = 360; y <= 600; y += 30) {
c.beginPath();
c.moveTo(0, y);
c.lineTo(600, y);
c.stroke();
}
c.fillStyle = '#ffffff';
c.shadowColor = '#00f0ff';
c.shadowBlur = 24;
c.font = '900 110px Orbitron, sans-serif';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText('VIZ', 300, 250);
const img = new Image();
img.src = off.toDataURL('image/png');
return img;
}
state.coverImage = generateDefaultCover();
// --- Audio Engine Setup ---
function initAudioContext() {
if (!state.audioCtx) {
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
state.audioCtx = new AudioCtxClass();
state.analyser = state.audioCtx.createAnalyser();
state.analyser.fftSize = 2048;
state.analyser.smoothingTimeConstant = 0.82;
state.gainNode = state.audioCtx.createGain();
state.gainNode.gain.value = parseFloat(volumeSlider.value);
state.gainNode.connect(state.audioCtx.destination);
}
if (state.audioCtx.state === 'suspended') {
state.audioCtx.resume();
}
}
async function loadAudioFile(file) {
if (!file) return;
initAudioContext();
stopPlayback();
audioFileBadge.textContent = 'Lade Audio-Datei...';
audioFileBadge.classList.remove('hidden');
state.audioFile = file;
const arrayBuffer = await file.arrayBuffer();
state.audioCtx.decodeAudioData(
arrayBuffer,
(decoded) => {
state.audioBuffer = decoded;
state.duration = decoded.duration;
state.audioLoaded = true;
durationTimeEl.textContent = formatTime(state.duration);
seekScrubber.max = state.duration;
seekScrubber.value = 0;
currentTimeEl.textContent = '00:00';
document.getElementById('timelineProgressBar').style.width = '0%';
audioFileBadge.textContent = `🎵 ${file.name} (${formatTime(state.duration)})`;
// Extract metadata if empty
const titleInput = document.getElementById('trackTitle');
if (titleInput && (!titleInput.value || titleInput.value === 'Dein Songtitel')) {
const clean = file.name.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' ');
titleInput.value = clean;
}
startPlayback(0);
},
(err) => {
alert('Fehler beim Dekodieren der Audio-Datei: ' + err);
audioFileBadge.textContent = 'Fehler beim Laden';
}
);
}
function loadImageFile(file) {
if (!file) return;
state.imageFile = file;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
state.coverImage = img;
imageFileBadge.textContent = `🖼️ ${file.name}`;
imageFileBadge.classList.remove('hidden');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function loadWasserzeichenLogo(onDone) {
const img = new Image();
img.onload = () => {
state.coverImage = img;
state.imageFile = null;
if (imageFileBadge) {
imageFileBadge.textContent = '🤖 Wasserzeichen (AIWentNonsense)';
imageFileBadge.classList.remove('hidden');
}
if (onDone) onDone(img);
};
img.onerror = () => {
console.warn('Wasserzeichen konnte nicht von assets/Wasserzeichen.png geladen werden, versuche Wasserzeichen.png');
const fallbackImg = new Image();
fallbackImg.onload = () => {
state.coverImage = fallbackImg;
state.imageFile = null;
if (imageFileBadge) {
imageFileBadge.textContent = '🤖 Wasserzeichen (AIWentNonsense)';
imageFileBadge.classList.remove('hidden');
}
if (onDone) onDone(fallbackImg);
};
fallbackImg.src = 'Wasserzeichen.png';
};
img.src = 'assets/Wasserzeichen.png';
}
function startPlayback(offsetSeconds = 0) {
initAudioContext();
if (!state.audioLoaded || !state.audioBuffer) {
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
return;
}
if (state.audioSource) {
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.audioSource = state.audioCtx.createBufferSource();
state.audioSource.buffer = state.audioBuffer;
state.audioSource.loop = state.loop;
state.audioSource.connect(state.analyser);
state.analyser.connect(state.gainNode);
const safeOffset = Math.max(0, Math.min(offsetSeconds, state.duration || 0));
state.startTime = state.audioCtx.currentTime - safeOffset;
state.pauseOffset = safeOffset;
state.audioSource.start(0, safeOffset);
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
state.audioSource.onended = () => {
if (!state.loop && state.isPlaying) {
pausePlayback();
state.pauseOffset = 0;
seekScrubber.value = 0;
currentTimeEl.textContent = '00:00';
document.getElementById('timelineProgressBar').style.width = '0%';
}
};
}
function pausePlayback() {
if (state.audioSource) {
state.pauseOffset = getCurrentPlaybackTime();
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.isPlaying = false;
iconPlay.classList.remove('hidden');
iconPause.classList.add('hidden');
}
function stopPlayback() {
if (state.audioSource) {
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.isPlaying = false;
state.pauseOffset = 0;
iconPlay.classList.remove('hidden');
iconPause.classList.add('hidden');
}
function getCurrentPlaybackTime() {
if (!state.audioLoaded || !state.duration) return 0;
if (state.isPlaying && state.audioCtx) {
let t = (state.audioCtx.currentTime - state.startTime);
if (state.loop) {
return (t % state.duration + state.duration) % state.duration;
}
return Math.min(Math.max(0, t), state.duration);
}
return state.pauseOffset || 0;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// --- Particles Initializer & Pool ---
function initParticles(count) {
state.particles = [];
for (let i = 0; i < count; i++) {
state.particles.push({
x: (Math.random() - 0.5) * state.width,
y: (Math.random() - 0.5) * state.height,
z: Math.random() * 1000 + 100,
radius: Math.random() * 2.8 + 1.0,
alpha: Math.random() * 0.7 + 0.3,
speedMultiplier: Math.random() * 0.8 + 0.6,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5
});
}
}
// --- Dynamic Color Gradient Builder ---
function createVisualizerGradient(targetCtx, cfg, radius, angle, time) {
const mode = cfg.colorMode || 'gradient_2';
const c1 = cfg.colorGrad1 || '#00f0ff';
const c2 = cfg.colorGrad2 || '#ff007f';
const c3 = cfg.colorGrad3 || '#7000ff';
const style = cfg.gradientStyle || 'angular';
if (mode === 'rainbow') {
const grad = targetCtx.createConicGradient(time * 0.6, 0, 0);
grad.addColorStop(0, '#ff0055');
grad.addColorStop(0.2, '#ffaa00');
grad.addColorStop(0.4, '#00ff88');
grad.addColorStop(0.6, '#00f0ff');
grad.addColorStop(0.8, '#8a2be2');
grad.addColorStop(1, '#ff0055');
return grad;
}
if (mode === 'solid') {
return c1;
}
if (style === 'angular') {
const grad = targetCtx.createConicGradient(angle, 0, 0);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.33, c2);
grad.addColorStop(0.66, c3);
grad.addColorStop(1, c1);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c1);
}
return grad;
} else if (style === 'radial') {
const grad = targetCtx.createRadialGradient(0, 0, radius * 0.4, 0, 0, radius * 2.2);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
} else if (style === 'linear_v') {
const grad = targetCtx.createLinearGradient(0, -radius * 1.5, 0, radius * 1.5);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
} else {
// Horizontal linear
const grad = targetCtx.createLinearGradient(-radius * 1.5, 0, radius * 1.5, 0);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
}
}
// --- Helper: Polygon Path Generator ---
function drawPolygonPath(targetCtx, sides, radius, cornerRadius = 0) {
targetCtx.beginPath();
if (sides === 0) {
// Circle
targetCtx.arc(0, 0, radius, 0, Math.PI * 2);
targetCtx.closePath();
return;
}
const angleStep = (Math.PI * 2) / sides;
const startAngle = -Math.PI / 2; // Point top
for (let i = 0; i < sides; i++) {
const a = startAngle + i * angleStep;
const x = Math.cos(a) * radius;
const y = Math.sin(a) * radius;
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.closePath();
}
// --- Helper: Hex to RGBA String ---
function hexToRgba(hex, alpha = 1.0) {
if (!hex || typeof hex !== 'string') return `rgba(255,255,255,${alpha})`;
let c = hex.replace('#', '');
if (c.length === 3) c = c.split('').map(x => x + x).join('');
const num = parseInt(c, 16);
if (isNaN(num)) return `rgba(255,255,255,${alpha})`;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// --- Ultra-Fast Offline FFT Engine for 100% Deterministic Frame Rendering ---
class FastFFT {
constructor(size = 2048) {
this.size = size;
this.cosTable = new Float32Array(size / 2);
this.sinTable = new Float32Array(size / 2);
this.hannWindow = new Float32Array(size);
this.bitReverse = new Uint32Array(size);
for (let i = 0; i < size / 2; i++) {
this.cosTable[i] = Math.cos(-2 * Math.PI * i / size);
this.sinTable[i] = Math.sin(-2 * Math.PI * i / size);
}
for (let i = 0; i < size; i++) {
this.hannWindow[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (size - 1)));
}
let limit = 1;
let bit = size >> 1;
while (limit < size) {
for (let i = 0; i < limit; i++) {
this.bitReverse[i + limit] = this.bitReverse[i] + bit;
}
limit <<= 1;
bit >>= 1;
}
this.real = new Float32Array(size);
this.imag = new Float32Array(size);
this.outputFreq = new Uint8Array(size / 2);
}
compute(samples, binCount = 1024) {
const N = this.size;
for (let i = 0; i < N; i++) {
const rev = this.bitReverse[i];
this.real[rev] = (samples[i] || 0) * this.hannWindow[i];
this.imag[rev] = 0;
}
for (let halfSize = 1; halfSize < N; halfSize <<= 1) {
const step = N / (halfSize << 1);
for (let i = 0; i < N; i += (halfSize << 1)) {
for (let j = 0; j < halfSize; j++) {
const tableIdx = j * step;
const cos = this.cosTable[tableIdx];
const sin = this.sinTable[tableIdx];
const match = i + j + halfSize;
const tr = this.real[match] * cos - this.imag[match] * sin;
const ti = this.real[match] * sin + this.imag[match] * cos;
this.real[match] = this.real[i + j] - tr;
this.imag[match] = this.imag[i + j] - ti;
this.real[i + j] += tr;
this.imag[i + j] += ti;
}
}
}
const outCount = Math.min(binCount, N / 2);
for (let i = 0; i < outCount; i++) {
const mag = Math.sqrt(this.real[i] * this.real[i] + this.imag[i] * this.imag[i]) / (N / 4);
const db = mag > 0.00001 ? 20 * Math.log10(mag) : -100;
const normalized = Math.max(0, Math.min(255, Math.round(((db + 90) / 75) * 255)));
this.outputFreq[i] = normalized;
}
return this.outputFreq;
}
}
const globalFastFFT = new FastFFT(2048);
// --- Helper: Precise Polygon Perimeter Point & Smooth Radial Normal ---
function getPolygonPerimeterPoint(sides, radius, t) {
if (!sides || sides < 3) {
const angle = t * Math.PI * 2 - Math.PI / 2;
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
nx: Math.cos(angle),
ny: Math.sin(angle),
angle: angle
};
}
const totalProgress = ((t % 1) + 1) % 1;
const sideProgress = totalProgress * sides;
const sideIdx = Math.floor(sideProgress) % sides;
const u = sideProgress - Math.floor(sideProgress);
const angleStep = (Math.PI * 2) / sides;
const a1 = -Math.PI / 2 + sideIdx * angleStep;
const a2 = -Math.PI / 2 + ((sideIdx + 1) % sides) * angleStep;
const x1 = Math.cos(a1) * radius;
const y1 = Math.sin(a1) * radius;
const x2 = Math.cos(a2) * radius;
const y2 = Math.sin(a2) * radius;
const px = x1 + u * (x2 - x1);
const py = y1 + u * (y2 - y1);
const dist = Math.hypot(px, py) || 1;
// Radial outward ray from center through perimeter point (eliminates vertex gaps & comb artifacts)
const nx = px / dist;
const ny = py / dist;
return {
x: px,
y: py,
nx: nx,
ny: ny,
angle: Math.atan2(py, px)
};
}
function renderFrame(targetCtx, width, height, freqData, currentTime, duration, cfg, isPlaying = true) {
targetCtx.save();
targetCtx.clearRect(0, 0, width, height);
// 1. Analyze Frequency Bands
let subBassSum = 0;
let midBassSum = 0;
let trebleSum = 0;
const binCount = freqData.length;
for (let i = 0; i < 8; i++) subBassSum += freqData[i] || 0;
for (let i = 8; i < 28; i++) midBassSum += freqData[i] || 0;
for (let i = 40; i < 140; i++) trebleSum += freqData[i] || 0;
const subBassAvg = (subBassSum / 8) / 255;
const midBassAvg = (midBassSum / 20) / 255;
const trebleAvg = (trebleSum / 100) / 255;
const rawBassEnergy = isPlaying ? Math.max(subBassAvg * 1.25, midBassAvg) : 0;
state.beatEnergy = rawBassEnergy;
// Target Pulse calculation with smooth attack/decay
const targetPulse = isPlaying ? (1.0 + (rawBassEnergy * cfg.pulseIntensity * 0.6)) : 1.0;
const decay = cfg.pulseDecay || 0.8;
if (targetPulse > state.currentPulse) {
state.currentPulse = targetPulse;
} else {
state.currentPulse = state.currentPulse * decay + targetPulse * (1 - decay);
}
// Camera Shake & Bass Zoom calculation
let shakeZoom = 1.0;
let shakeX = 0;
let shakeY = 0;
if (isPlaying && cfg.enableShake && cfg.camShakeIntensity > 0 && rawBassEnergy > 0.35) {
const shakeFactor = (rawBassEnergy - 0.35) * cfg.camShakeIntensity * 38;
shakeX = (Math.random() - 0.5) * shakeFactor;
shakeY = (Math.random() - 0.5) * shakeFactor;
}
if (isPlaying && cfg.enableBassZoom && rawBassEnergy > 0.3) {
shakeZoom = 1.0 + (rawBassEnergy - 0.3) * 0.07;
}
// Rotation calculation
if (isPlaying) {
const rpm = cfg.rotationSpeed || 0;
state.currentRotation += (rpm * Math.PI * 2 / (60 * 60));
if (cfg.beatSpinImpulse && rawBassEnergy > 0.7) {
state.currentRotation += (rawBassEnergy - 0.7) * 0.045;
}
}
// --- LAYER 1: Background Mode ---
if (cfg.backgroundMode === 'blurred_cover' && state.coverImage && cfg.showCover) {
targetCtx.save();
targetCtx.filter = 'blur(45px) brightness(0.35)';
const scale = Math.max(width / state.coverImage.width, height / state.coverImage.height) * 1.25 * shakeZoom;
targetCtx.drawImage(
state.coverImage,
width / 2 - (state.coverImage.width * scale) / 2 + shakeX,
height / 2 - (state.coverImage.height * scale) / 2 + shakeY,
state.coverImage.width * scale,
state.coverImage.height * scale
);
targetCtx.restore();
} else if (cfg.backgroundMode === 'dark_vignette') {
const vig = targetCtx.createRadialGradient(width / 2, height / 2, width * 0.2, width / 2, height / 2, width * 0.75);
vig.addColorStop(0, '#101524');
vig.addColorStop(1, '#040609');
targetCtx.fillStyle = vig;
targetCtx.fillRect(0, 0, width, height);
} else if (cfg.backgroundMode === 'solid_black') {
targetCtx.fillStyle = '#000000';
targetCtx.fillRect(0, 0, width, height);
} // Transparent keeps pure alpha!
// --- LAYER 2: 3D Synthwave Grid Floor ---
if (cfg.enableGrid) {
targetCtx.save();
const horizonY = height * (cfg.gridPerspective || 0.65);
const gridColor = cfg.colorGrid || '#ff007f';
if (isPlaying) {
const speed = 120 * (1.0 + rawBassEnergy * 1.5);
state.gridOffset = (state.gridOffset + speed / 60) % 50;
}
targetCtx.strokeStyle = gridColor;
targetCtx.shadowColor = gridColor;
targetCtx.shadowBlur = cfg.enableGlow ? (15 * (cfg.glowIntensity || 0.8)) : 0;
targetCtx.lineWidth = 1.5;
targetCtx.globalAlpha = 0.85;
// Horizon glow line
targetCtx.beginPath();
targetCtx.moveTo(0, horizonY);
targetCtx.lineTo(width, horizonY);
targetCtx.stroke();
// Perspective vertical lines (radiating to vanishing point)
const numLines = 26;
for (let i = 0; i <= numLines; i++) {
const xBottom = (width / numLines) * i;
const xVanishing = width / 2 + (xBottom - width / 2) * 0.15;
targetCtx.beginPath();
targetCtx.moveTo(xVanishing, horizonY);
targetCtx.lineTo(xBottom, height);
targetCtx.stroke();
}
// Horizontal depth lines
for (let y = 0; y < 14; y++) {
const t = Math.pow((y + state.gridOffset / 50) / 14, 2.2);
const curY = horizonY + (height - horizonY) * t;
const wave = cfg.gridAudioReactive ? Math.sin(y * 0.8 + currentTime * 4) * (rawBassEnergy * 18) : 0;
targetCtx.beginPath();
targetCtx.moveTo(0, curY + wave);
targetCtx.lineTo(width, curY + wave);
targetCtx.stroke();
}
targetCtx.restore();
}
// --- LAYER 3: Beat Flash FX (Subtle core glow pulse, no full-screen white flash) ---
// (Full-screen canvas flashes disabled to prevent video flickering & compression banding)
// Transform Matrix for Centered World Coordinates & Camera Shake
targetCtx.save();
targetCtx.translate(width / 2 + shakeX, height / 2 + shakeY);
targetCtx.scale(shakeZoom, shakeZoom);
// --- LAYER 4: Particle System (3D Starfield / Dust / Sparks) ---
if (cfg.enableParticles && state.particles.length > 0) {
targetCtx.save();
const pType = cfg.particleType || 'starfield';
const speedMult = cfg.particleSpeed * (1.0 + (rawBassEnergy * cfg.particleWarp));
for (let i = 0; i < state.particles.length; i++) {
const p = state.particles[i];
if (isPlaying) {
if (pType === 'starfield') {
p.z -= speedMult * p.speedMultiplier * 6;
if (p.z <= 10) {
p.z = 1000;
p.x = (Math.random() - 0.5) * width;
p.y = (Math.random() - 0.5) * height;
}
} else if (pType === 'dust') {
p.x += p.vx * speedMult;
p.y += p.vy * speedMult;
if (p.x < -width / 2) p.x = width / 2;
if (p.x > width / 2) p.x = -width / 2;
if (p.y < -height / 2) p.y = height / 2;
if (p.y > height / 2) p.y = -height / 2;
} else if (pType === 'sparks') {
p.x += p.vx * speedMult * (1 + rawBassEnergy * 3);
p.y += p.vy * speedMult * (1 + rawBassEnergy * 3);
if (Math.hypot(p.x, p.y) > width * 0.6) {
p.x = (Math.random() - 0.5) * (cfg.circleRadius || 100);
p.y = (Math.random() - 0.5) * (cfg.circleRadius || 100);
}
}
}
const isTransparentMode = (cfg.backgroundMode === 'transparent');
if (pType === 'starfield') {
const k = 400 / p.z;
const px = p.x * k;
const py = p.y * k;
const pSize = Math.max(0.75, p.radius * k);
if (Math.abs(px) < width / 2 && Math.abs(py) < height / 2) {
let pAlpha = p.alpha * (1 - p.z / 1000);
if (isTransparentMode) {
// In transparent mode, skip ultra-faint distant particles to prevent lossy VP9 macroblock artifacts
if (pAlpha < 0.22 || p.z > 750) continue;
pAlpha = Math.max(0.5, pAlpha);
}
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = Math.min(1.0, pAlpha);
targetCtx.beginPath();
targetCtx.arc(px, py, pSize, 0, Math.PI * 2);
targetCtx.fill();
}
} else if (pType === 'dust') {
let pAlpha = p.alpha * 0.6;
if (isTransparentMode) {
if (pAlpha < 0.22) continue;
pAlpha = Math.max(0.5, pAlpha);
}
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = pAlpha;
targetCtx.beginPath();
targetCtx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
targetCtx.fill();
} else if (pType === 'sparks') {
let pAlpha = p.alpha * (0.8 + rawBassEnergy * 0.5);
if (isTransparentMode) {
pAlpha = Math.max(0.6, pAlpha);
}
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = Math.min(1.0, pAlpha);
targetCtx.beginPath();
targetCtx.arc(p.x, p.y, p.radius * 1.2, 0, Math.PI * 2);
targetCtx.fill();
}
}
targetCtx.restore();
}
// Prepare Shape Geometry Values
const shape = cfg.shapeType || 'circle';
const baseRadius = (cfg.circleRadius || 150) * state.currentPulse;
const barCount = cfg.barCount || 96;
const maxHeight = cfg.spectrumMaxHeight || 140;
const barSpacing = cfg.barSpacing || 10;
const sensitivity = cfg.freqSensitivity || 1.3;
// Filter Frequency Array for Bars
const processedBars = [];
for (let i = 0; i < barCount; i++) {
let freqIndex;
if (cfg.mirrorSpectrum) {
const half = barCount / 2;
const symIdx = i < half ? i : barCount - i;
freqIndex = Math.floor((symIdx / half) * (binCount * 0.45));
} else {
freqIndex = Math.floor((i / barCount) * (binCount * 0.45));
}
if (cfg.freqFocus === 'bass_heavy') {
freqIndex = Math.floor(Math.pow(i / barCount, 1.8) * (binCount * 0.35));
}
const val = (freqData[freqIndex] || 0) / 255;
const heightVal = Math.pow(val, 1.2) * maxHeight * sensitivity;
processedBars.push(Math.max(2, heightVal));
}
// Build Visualizer Color Gradient
const spectrumGrad = createVisualizerGradient(
targetCtx,
cfg,
baseRadius,
state.currentRotation,
currentTime
);
// --- LAYER 5: Volumetric Neon Bloom & Background Aura (Clean Vector Glow) ---
// (Muddy circular radial gradients removed to ensure 100% crystal-clear Alpha channel without dark banding)
// 3D Tunnel / Wormhole (If Shape === 'tunnel')
if (shape === 'tunnel') {
targetCtx.save();
const numRings = 10;
for (let r = numRings; r >= 1; r--) {
const depth = r + (currentTime * 1.5) % 1;
const ringRadius = baseRadius * (depth / numRings) * 2.2;
const ringAlpha = (1 - depth / (numRings + 1)) * 0.8;
const ringPulse = 1.0 + (rawBassEnergy * (1.2 - r / numRings) * 0.5);
targetCtx.strokeStyle = r % 2 === 0 ? cfg.colorGrad1 : cfg.colorGrad2;
targetCtx.lineWidth = Math.max(1, 4 * (1 - depth / numRings));
targetCtx.globalAlpha = ringAlpha;
targetCtx.beginPath();
targetCtx.arc(0, 0, ringRadius * ringPulse, 0, Math.PI * 2);
targetCtx.stroke();
}
targetCtx.restore();
}
// --- LAYER 6: Audio Spectrum Rendering (Radial, Polygon, Linear, Oscilloscope) ---
if (cfg.showSpectrum) {
targetCtx.save();
targetCtx.fillStyle = spectrumGrad;
targetCtx.strokeStyle = spectrumGrad;
// Subtle edge glow on individual elements (crisp, not bloated)
if (cfg.enableGlow && cfg.glowIntensity > 0) {
targetCtx.shadowColor = cfg.colorGrad1 || '#00f0ff';
targetCtx.shadowBlur = Math.min(6, (cfg.glowBlur || 20) * 0.15);
}
// 6.1 LINEAR & HORIZON MODE
if (shape === 'linear') {
const align = cfg.linearAlignment || 'center_mirrored';
const totalW = width * 0.85;
const barW = cfg.barWidth || 5;
const step = totalW / barCount;
const startX = -totalW / 2;
if (cfg.spectrumMode === 'smooth_wave') {
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
const y = align === 'bottom_up' ? height * 0.35 - h : (align === 'top_down' ? -height * 0.35 + h : -h);
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.lineWidth = cfg.barWidth * 1.5;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (align === 'center_mirrored') {
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
if (i === 0) targetCtx.moveTo(x, h);
else targetCtx.lineTo(x, h);
}
targetCtx.stroke();
}
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.5);
targetCtx.stroke();
targetCtx.restore();
}
} else if (cfg.spectrumMode === 'block_matrix') {
const blockSize = 6;
const blockGap = 3;
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
const blocks = Math.floor(h / (blockSize + blockGap));
for (let b = 0; b < blocks; b++) {
const by = align === 'bottom_up' ? height * 0.38 - (b * (blockSize + blockGap)) : - (b * (blockSize + blockGap));
targetCtx.fillRect(x - barW / 2, by, barW, blockSize);
if (align === 'center_mirrored') {
targetCtx.fillRect(x - barW / 2, b * (blockSize + blockGap), barW, blockSize);
}
}
}
} else {
// Standard Linear Bars (Crisp rounded lines)
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
targetCtx.beginPath();
if (align === 'center_mirrored') {
targetCtx.moveTo(x, -h);
targetCtx.lineTo(x, h);
} else if (align === 'bottom_up') {
targetCtx.moveTo(x, height * 0.38);
targetCtx.lineTo(x, height * 0.38 - h);
} else if (align === 'top_down') {
targetCtx.moveTo(x, -height * 0.38);
targetCtx.lineTo(x, -height * 0.38 + h);
} else {
targetCtx.moveTo(x, -h / 2);
targetCtx.lineTo(x, h / 2);
}
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
targetCtx.lineWidth = Math.max(1, barW * 0.35);
targetCtx.stroke();
targetCtx.restore();
}
}
}
// 6.2 OSCILLOSCOPE / LASER WAVE MODE
} else if (shape === 'oscilloscope') {
const totalW = width * 0.85;
const step = totalW / barCount;
const startX = -totalW / 2;
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const val = (freqData[i % binCount] || 0) / 255;
const oscY = Math.sin(i * 0.15 + currentTime * 8) * (val * maxHeight * 1.5) + (Math.cos(i * 0.3) * val * 20);
if (i === 0) targetCtx.moveTo(x, oscY);
else targetCtx.lineTo(x, oscY);
}
targetCtx.lineWidth = cfg.barWidth * 1.8;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.6);
targetCtx.stroke();
targetCtx.restore();
}
// 6.3 POLYGON & RADIAL SPECTRUMS (Circle, Hexagon, Diamond, Triangle, Octagon, Tunnel)
} else {
const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, tunnel: 0 };
const sides = sidesMap[shape] || 0;
const barW = cfg.barWidth || 4;
if (cfg.spectrumMode === 'radial_bars') {
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const barH = processedBars[i];
const startDist = barSpacing;
const endDist = barSpacing + barH;
const x1 = pt.x + pt.nx * startDist;
const y1 = pt.y + pt.ny * startDist;
const x2 = pt.x + pt.nx * endDist;
const y2 = pt.y + pt.ny * endDist;
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
targetCtx.lineWidth = Math.max(1, barW * 0.35);
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
targetCtx.restore();
}
}
} else if (cfg.spectrumMode === 'smooth_wave') {
targetCtx.beginPath();
for (let i = 0; i <= barCount; i++) {
const idx = i % barCount;
const t = idx / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + processedBars[idx];
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.closePath();
targetCtx.lineWidth = cfg.barWidth * 1.5;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.5);
targetCtx.stroke();
targetCtx.restore();
}
} else if (cfg.spectrumMode === 'floating_dots') {
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + processedBars[i];
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
targetCtx.beginPath();
targetCtx.arc(x, y, cfg.barWidth * 1.1, 0, Math.PI * 2);
targetCtx.fill();
}
} else if (cfg.spectrumMode === 'dual_ring') {
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
// Inner Ring
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const barH = processedBars[i] * 0.7;
const x1 = pt.x + pt.nx * barSpacing;
const y1 = pt.y + pt.ny * barSpacing;
const x2 = pt.x + pt.nx * (barSpacing + barH);
const y2 = pt.y + pt.ny * (barSpacing + barH);
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
}
// Outer Ring (Orbital Dots)
targetCtx.fillStyle = cfg.colorGrad2 || '#ff007f';
for (let i = 0; i < barCount; i += 2) {
const t = (i / barCount + (currentTime * 0.05)) % 1;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + maxHeight * 0.8 + (processedBars[i] * 0.4);
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
targetCtx.beginPath();
targetCtx.arc(x, y, barW * 0.9, 0, Math.PI * 2);
targetCtx.fill();
}
}
}
targetCtx.restore();
}
// --- LAYER 7: Central Core Artwork & Shape Mask ---
const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, linear: 0, oscilloscope: 0, tunnel: 0 };
const polySides = sidesMap[shape] || 0;
if (cfg.showCover && state.coverImage && shape !== 'linear' && shape !== 'oscilloscope') {
targetCtx.save();
targetCtx.imageSmoothingEnabled = true;
targetCtx.imageSmoothingQuality = 'high';
drawPolygonPath(targetCtx, polySides, baseRadius);
targetCtx.clip();
// Only rotate cover art if shape is circle (keeps album art upright in polygons)
if (polySides < 3 && cfg.rotationSpeed) {
targetCtx.rotate(state.currentRotation);
}
targetCtx.drawImage(
state.coverImage,
-baseRadius,
-baseRadius,
baseRadius * 2,
baseRadius * 2
);
targetCtx.restore();
}
// --- LAYER 8: Shape Border & Progress Ring ---
if (cfg.showBorder && (cfg.borderWidth || 6) > 0 && shape !== 'linear' && shape !== 'oscilloscope') {
targetCtx.save();
drawPolygonPath(targetCtx, polySides, baseRadius);
targetCtx.lineWidth = cfg.borderWidth || 6;
targetCtx.strokeStyle = cfg.borderColor || '#ffffff';
targetCtx.lineJoin = 'round';
if (cfg.enableGlow) {
targetCtx.shadowColor = cfg.borderColor || '#ffffff';
targetCtx.shadowBlur = 8;
}
targetCtx.stroke();
targetCtx.restore();
}
if (cfg.showProgressRing && duration > 0 && shape !== 'linear' && shape !== 'oscilloscope') {
const progress = Math.min(1, Math.max(0, currentTime / duration));
const ringW = cfg.progressRingWidth || 6;
const ringRadius = baseRadius + (cfg.borderWidth || 6) / 2 + 2;
targetCtx.save();
targetCtx.lineWidth = ringW;
targetCtx.strokeStyle = cfg.progressRingColor || '#00f0ff';
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
if (cfg.enableGlow) {
targetCtx.shadowColor = cfg.progressRingColor || '#00f0ff';
targetCtx.shadowBlur = 10;
}
targetCtx.beginPath();
if (polySides < 3) {
const progressAngle = progress * (Math.PI * 2);
targetCtx.arc(0, 0, ringRadius, -Math.PI / 2, -Math.PI / 2 + progressAngle);
} else {
const steps = polySides * 40;
const targetStep = Math.floor(progress * steps);
for (let s = 0; s <= targetStep; s++) {
const t = s / steps;
const pt = getPolygonPerimeterPoint(polySides, ringRadius, t);
if (s === 0) targetCtx.moveTo(pt.x, pt.y);
else targetCtx.lineTo(pt.x, pt.y);
}
if (progress > 0 && targetStep < steps) {
const ptEnd = getPolygonPerimeterPoint(polySides, ringRadius, progress);
targetCtx.lineTo(ptEnd.x, ptEnd.y);
}
}
targetCtx.stroke();
targetCtx.restore();
}
targetCtx.restore(); // Restore Centered Coordinates
// --- LAYER 9: Typography & Song Info (Crisp Studio Typography) ---
if (cfg.showTextOverlay) {
const showTitle = cfg.showTrackTitle !== false;
const showArtist = cfg.showTrackArtist !== false;
const showTime = cfg.showTimecode && duration > 0;
const showProg = cfg.showProgressBar && duration > 0;
let title = (document.getElementById('trackTitle').value || 'Song Title').trim();
let artist = (document.getElementById('trackArtist').value || 'Artist').trim();
if (cfg.titleUppercase) title = title.toUpperCase();
if (cfg.artistUppercase) artist = artist.toUpperCase();
const titleSize = cfg.titleFontSize || 38;
const artistSize = cfg.artistFontSize || 22;
const timeSize = cfg.timecodeFontSize || 16;
const spacing = cfg.textSpacing ?? 10;
const offsetY = cfg.textOffsetY || 0;
const family = cfg.fontFamily || "'Outfit', sans-serif";
targetCtx.save();
targetCtx.textAlign = 'center';
targetCtx.textBaseline = 'middle';
// Base Y Calculation based on Position
let baseY = height / 2 + baseRadius + maxHeight + 75; // below_circle default
if (cfg.textPosition === 'above_circle') {
baseY = height / 2 - (baseRadius + maxHeight + 75);
} else if (cfg.textPosition === 'bottom_center') {
baseY = height - 100;
} else if (cfg.textPosition === 'top_center') {
baseY = 100;
} else if (cfg.textPosition === 'center_inside') {
baseY = height / 2;
}
baseY += offsetY;
const textPulse = cfg.textPulseWithBeat ? (1.0 + rawBassEnergy * 0.05) : 1.0;
targetCtx.translate(width / 2, baseY);
targetCtx.scale(textPulse, textPulse);
// Contrast Shadow / Glow
if (cfg.textGlow) {
targetCtx.shadowColor = cfg.colorGrad1 || '#00f0ff';
targetCtx.shadowBlur = 14;
} else {
if (cfg.backgroundMode === 'transparent') {
// Never draw muddy black shadows into pure alpha channels
targetCtx.shadowColor = 'transparent';
targetCtx.shadowBlur = 0;
targetCtx.shadowOffsetX = 0;
targetCtx.shadowOffsetY = 0;
} else {
targetCtx.shadowColor = 'rgba(0, 0, 0, 0.85)';
targetCtx.shadowBlur = 6;
targetCtx.shadowOffsetX = 0;
targetCtx.shadowOffsetY = 2;
}
}
// Gather active elements
const elements = [];
if (showTitle && title) {
elements.push({ type: 'title', height: titleSize, text: title });
}
if (showArtist && artist) {
elements.push({ type: 'artist', height: artistSize, text: artist });
}
if (showTime) {
let timeStr = `${formatTime(currentTime)} / ${formatTime(duration)}`;
if (cfg.timecodeMode === 'elapsed_only') {
timeStr = formatTime(currentTime);
} else if (cfg.timecodeMode === 'remaining') {
const rem = Math.max(0, duration - currentTime);
timeStr = `-${formatTime(rem)}`;
}
elements.push({ type: 'timecode', height: timeSize, text: timeStr });
}
if (showProg) {
elements.push({ type: 'progressbar', height: cfg.progressBarHeight || 4 });
}
if (elements.length > 0) {
// Calculate total stacked height
let totalH = 0;
for (let i = 0; i < elements.length; i++) {
totalH += elements[i].height;
if (i < elements.length - 1) totalH += spacing;
}
// Draw each element from top of stack
let curY = -totalH / 2;
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const centerY = curY + el.height / 2;
if (el.type === 'title') {
targetCtx.font = `${cfg.titleFontWeight || '700'} ${titleSize}px ${family}`;
targetCtx.fillStyle = cfg.titleColor || '#ffffff';
targetCtx.fillText(el.text, 0, centerY);
} else if (el.type === 'artist') {
targetCtx.font = `${cfg.artistFontWeight || '500'} ${artistSize}px ${family}`;
targetCtx.fillStyle = cfg.artistColor || '#cccccc';
targetCtx.fillText(el.text, 0, centerY);
} else if (el.type === 'timecode') {
targetCtx.font = `600 ${timeSize}px ${family}`;
targetCtx.fillStyle = cfg.timecodeColor || '#00f0ff';
targetCtx.fillText(el.text, 0, centerY);
} else if (el.type === 'progressbar') {
const barW = Math.min(width * 0.8, cfg.progressBarWidth || 380);
const barH = cfg.progressBarHeight || 4;
const barY = centerY - barH / 2;
targetCtx.save();
targetCtx.shadowBlur = 0;
targetCtx.fillStyle = 'rgba(255, 255, 255, 0.18)';
targetCtx.beginPath();
if (targetCtx.roundRect) {
targetCtx.roundRect(-barW / 2, barY, barW, barH, barH / 2);
} else {
targetCtx.rect(-barW / 2, barY, barW, barH);
}
targetCtx.fill();
const fillW = Math.min(barW, Math.max(0, (currentTime / duration) * barW));
if (fillW > 0) {
targetCtx.fillStyle = cfg.progressBarColor || '#00f0ff';
if (cfg.textGlow) {
targetCtx.shadowColor = cfg.progressBarColor || '#00f0ff';
targetCtx.shadowBlur = 8;
}
targetCtx.beginPath();
if (targetCtx.roundRect) {
targetCtx.roundRect(-barW / 2, barY, fillW, barH, barH / 2);
} else {
targetCtx.rect(-barW / 2, barY, fillW, barH);
}
targetCtx.fill();
}
targetCtx.restore();
}
curY += el.height + spacing;
}
}
targetCtx.restore();
}
targetCtx.restore(); // Restore Root Context
}
// --- Real-Time Animation Loop (60 FPS) ---
const freqArray = new Uint8Array(1024);
function mainLoop() {
if (!state.isExporting) {
const curTime = getCurrentPlaybackTime();
if (state.isPlaying) {
if (state.audioLoaded && state.analyser) {
state.analyser.getByteFrequencyData(freqArray);
} else if (!state.audioLoaded) {
// Idle demo wave animation
const t = performance.now() * 0.003;
for (let i = 0; i < freqArray.length; i++) {
freqArray[i] = Math.max(0, Math.sin(t + i * 0.08) * 32);
}
}
} else {
// Paused: smoothly decay frequencies to 0 so visualizer stays quiet & frozen
for (let i = 0; i < freqArray.length; i++) {
freqArray[i] = Math.floor(freqArray[i] * 0.8);
}
}
if (state.isPlaying && state.audioLoaded) {
currentTimeEl.textContent = formatTime(curTime);
seekScrubber.value = curTime;
const pct = (curTime / (state.duration || 1)) * 100;
document.getElementById('timelineProgressBar').style.width = `${pct}%`;
}
renderFrame(ctx, state.width, state.height, freqArray, curTime, state.duration, state.config, state.isPlaying);
}
requestAnimationFrame(mainLoop);
}
// --- UI Synchronizer ---
function updateConfigFromUI() {
const checkedShape = document.querySelector('input[name="shapeType"]:checked');
if (checkedShape) state.config.shapeType = checkedShape.value;
state.config.showCover = document.getElementById('showCover').checked;
state.config.circleRadius = parseInt(document.getElementById('circleRadius').value, 10);
state.config.pulseIntensity = parseFloat(document.getElementById('pulseIntensity').value);
state.config.pulseDecay = parseFloat(document.getElementById('pulseDecay').value);
state.config.showBorder = document.getElementById('showBorder').checked;
state.config.borderWidth = parseInt(document.getElementById('borderWidth').value, 10);
state.config.borderColor = document.getElementById('borderColor').value;
state.config.showProgressRing = document.getElementById('showProgressRing').checked;
state.config.progressRingColor = document.getElementById('progressRingColor').value;
state.config.progressRingWidth = parseInt(document.getElementById('progressRingWidth').value, 10);
state.config.rotationSpeed = parseFloat(document.getElementById('rotationSpeed').value);
state.config.beatSpinImpulse = document.getElementById('beatSpinImpulse').checked;
state.config.showSpectrum = document.getElementById('showSpectrum').checked;
const checkedMode = document.querySelector('input[name="spectrumMode"]:checked');
if (checkedMode) state.config.spectrumMode = checkedMode.value;
state.config.linearAlignment = document.getElementById('linearAlignment').value;
state.config.barCount = parseInt(document.getElementById('barCount').value, 10);
state.config.spectrumMaxHeight = parseInt(document.getElementById('spectrumMaxHeight').value, 10);
state.config.barWidth = parseFloat(document.getElementById('barWidth').value);
state.config.barSpacing = parseInt(document.getElementById('barSpacing').value, 10);
state.config.barCornerRadius = parseFloat(document.getElementById('barCornerRadius').value);
state.config.freqSensitivity = parseFloat(document.getElementById('freqSensitivity').value);
state.config.freqFocus = document.getElementById('freqFocus').value;
state.config.mirrorSpectrum = document.getElementById('mirrorSpectrum').checked;
state.config.enableParticles = document.getElementById('enableParticles').checked;
state.config.particleType = document.getElementById('particleType').value;
state.config.particleCount = parseInt(document.getElementById('particleCount').value, 10);
state.config.particleSpeed = parseFloat(document.getElementById('particleSpeed').value);
state.config.particleWarp = parseFloat(document.getElementById('particleWarp').value);
state.config.enableGrid = document.getElementById('enableGrid').checked;
state.config.colorGrid = document.getElementById('colorGrid').value;
state.config.gridPerspective = parseFloat(document.getElementById('gridPerspective').value);
state.config.gridAudioReactive = parseFloat(document.getElementById('gridAudioReactive').value);
state.config.enableShake = document.getElementById('enableShake').checked;
state.config.camShakeIntensity = parseFloat(document.getElementById('camShakeIntensity').value);
state.config.enableGlitch = document.getElementById('enableGlitch').checked;
state.config.rgbSplitIntensity = parseFloat(document.getElementById('rgbSplitIntensity').value);
state.config.beatFlash = document.getElementById('beatFlash').checked;
state.config.enableBassZoom = document.getElementById('enableBassZoom').checked;
state.config.colorMode = document.getElementById('colorMode').value;
state.config.colorGrad1 = document.getElementById('colorGrad1').value;
state.config.colorGrad2 = document.getElementById('colorGrad2').value;
state.config.colorGrad3 = document.getElementById('colorGrad3').value;
state.config.colorParticle = document.getElementById('colorParticle').value;
state.config.gradientStyle = document.getElementById('gradientStyle').value;
state.config.enableGlow = document.getElementById('enableGlow').checked;
state.config.glowIntensity = parseFloat(document.getElementById('glowIntensity').value);
state.config.glowBlur = parseInt(document.getElementById('glowBlur').value, 10);
state.config.laserCore = document.getElementById('laserCore').checked;
state.config.backgroundMode = document.getElementById('backgroundMode').value;
state.config.showTextOverlay = document.getElementById('showTextOverlay').checked;
state.config.fontFamily = document.getElementById('fontFamily').value;
state.config.textPosition = document.getElementById('textPosition').value;
state.config.textOffsetY = parseInt(document.getElementById('textOffsetY').value, 10) || 0;
state.config.textSpacing = parseInt(document.getElementById('textSpacing').value, 10) || 10;
state.config.textPulseWithBeat = document.getElementById('textPulseWithBeat').checked;
state.config.textGlow = document.getElementById('textGlow').checked;
state.config.showTrackTitle = document.getElementById('showTrackTitle').checked;
state.config.titleFontSize = parseInt(document.getElementById('titleFontSize').value, 10) || 38;
state.config.titleColor = document.getElementById('titleColor').value || '#ffffff';
state.config.titleFontWeight = document.getElementById('titleFontWeight').value || '700';
state.config.titleUppercase = document.getElementById('titleUppercase').checked;
state.config.showTrackArtist = document.getElementById('showTrackArtist').checked;
state.config.artistFontSize = parseInt(document.getElementById('artistFontSize').value, 10) || 22;
state.config.artistColor = document.getElementById('artistColor').value || '#cccccc';
state.config.artistFontWeight = document.getElementById('artistFontWeight').value || '500';
state.config.artistUppercase = document.getElementById('artistUppercase').checked;
state.config.showTimecode = document.getElementById('showTimecode').checked;
state.config.timecodeFontSize = parseInt(document.getElementById('timecodeFontSize').value, 10) || 16;
state.config.timecodeColor = document.getElementById('timecodeColor').value || '#00f0ff';
state.config.timecodeMode = document.getElementById('timecodeMode').value || 'elapsed_and_total';
state.config.showProgressBar = document.getElementById('showProgressBar').checked;
state.config.progressBarWidth = parseInt(document.getElementById('progressBarWidth').value, 10) || 380;
state.config.progressBarHeight = parseInt(document.getElementById('progressBarHeight').value, 10) || 4;
state.config.progressBarColor = document.getElementById('progressBarColor').value || '#00f0ff';
// Sub-containers opacity / display
const textOpts = document.getElementById('textOptionsContainer');
const titleOpts = document.getElementById('titleOptionsContainer');
const artistOpts = document.getElementById('artistOptionsContainer');
const timeOpts = document.getElementById('timecodeOptionsContainer');
const progOpts = document.getElementById('progressBarOptionsContainer');
if (textOpts) textOpts.style.opacity = state.config.showTextOverlay ? '1' : '0.35';
if (titleOpts) titleOpts.style.opacity = (state.config.showTextOverlay && state.config.showTrackTitle) ? '1' : '0.35';
if (artistOpts) artistOpts.style.opacity = (state.config.showTextOverlay && state.config.showTrackArtist) ? '1' : '0.35';
if (timeOpts) timeOpts.style.opacity = (state.config.showTextOverlay && state.config.showTimecode) ? '1' : '0.35';
if (progOpts) progOpts.style.opacity = (state.config.showTextOverlay && state.config.showProgressBar) ? '1' : '0.35';
// Toggle container visibility based on shape & toggles
document.getElementById('linearAlignmentSection').style.display = state.config.shapeType === 'linear' ? 'block' : 'none';
coverUploadContainer.style.opacity = state.config.showCover ? '1' : '0.4';
// Update value badge labels
document.getElementById('valCircleRadius').textContent = `${state.config.circleRadius}px`;
document.getElementById('valPulseIntensity').textContent = `${Math.round(state.config.pulseIntensity * 100)}%`;
document.getElementById('valPulseDecay').textContent = `${Math.round(state.config.pulseDecay * 100)}%`;
document.getElementById('valBorderWidth').textContent = `${state.config.borderWidth}px`;
document.getElementById('valProgressRingWidth').textContent = `${state.config.progressRingWidth}px`;
document.getElementById('valRotationSpeed').textContent = `${state.config.rotationSpeed} RPM`;
document.getElementById('valBarCount').textContent = state.config.barCount;
document.getElementById('valSpectrumMaxHeight').textContent = `${state.config.spectrumMaxHeight}px`;
document.getElementById('valBarWidth').textContent = `${state.config.barWidth}px`;
document.getElementById('valBarSpacing').textContent = `${state.config.barSpacing}px`;
document.getElementById('valBarCornerRadius').textContent = `${state.config.barCornerRadius}px`;
document.getElementById('valFreqSensitivity').textContent = `${state.config.freqSensitivity}x`;
document.getElementById('valParticleCount').textContent = state.config.particleCount;
document.getElementById('valParticleSpeed').textContent = `${state.config.particleSpeed}x`;
document.getElementById('valParticleWarp').textContent = `${state.config.particleWarp}x`;
document.getElementById('valGridPerspective').textContent = `${Math.round(state.config.gridPerspective * 100)}%`;
document.getElementById('valGridAudioReactive').textContent = `${Math.round(state.config.gridAudioReactive * 100)}%`;
document.getElementById('valCamShakeIntensity').textContent = `${Math.round(state.config.camShakeIntensity * 100)}%`;
document.getElementById('valRgbSplitIntensity').textContent = `${Math.round(state.config.rgbSplitIntensity * 100)}%`;
document.getElementById('valGlowIntensity').textContent = `${Math.round(state.config.glowIntensity * 100)}%`;
document.getElementById('valGlowBlur').textContent = `${state.config.glowBlur}px`;
document.getElementById('valTextOffsetY').textContent = `${state.config.textOffsetY}px`;
document.getElementById('valTextSpacing').textContent = `${state.config.textSpacing}px`;
document.getElementById('valTitleFontSize').textContent = `${state.config.titleFontSize}px`;
document.getElementById('valArtistFontSize').textContent = `${state.config.artistFontSize}px`;
document.getElementById('valTimecodeFontSize').textContent = `${state.config.timecodeFontSize}px`;
document.getElementById('valProgressBarWidth').textContent = `${state.config.progressBarWidth}px`;
document.getElementById('valProgressBarHeight').textContent = `${state.config.progressBarHeight}px`;
initParticles(state.config.particleCount);
}
function applyPreset(presetKeyOrObj) {
const presetsObj = (typeof window !== 'undefined' && window.VIZ_PRESETS) || (typeof VIZ_PRESETS !== 'undefined' ? VIZ_PRESETS : {});
let p = typeof presetKeyOrObj === 'string' ? presetsObj[presetKeyOrObj] : presetKeyOrObj;
if (!p) return;
if (typeof presetKeyOrObj === 'string' && quickPresetSelect) {
quickPresetSelect.value = presetKeyOrObj;
}
if (p.coverUrl) {
loadWasserzeichenLogo();
}
Object.assign(state.config, p);
// Sync form inputs with preset
const shapeRadio = document.querySelector(`input[name="shapeType"][value="${state.config.shapeType || 'circle'}"]`);
if (shapeRadio) shapeRadio.checked = true;
document.getElementById('showCover').checked = state.config.showCover !== false;
document.getElementById('circleRadius').value = state.config.circleRadius || 150;
document.getElementById('pulseIntensity').value = state.config.pulseIntensity ?? 0.45;
document.getElementById('pulseDecay').value = state.config.pulseDecay ?? 0.8;
document.getElementById('showBorder').checked = state.config.showBorder !== false;
document.getElementById('borderWidth').value = state.config.borderWidth || 6;
document.getElementById('borderColor').value = state.config.borderColor || '#ffffff';
document.getElementById('showProgressRing').checked = state.config.showProgressRing !== false;
document.getElementById('progressRingColor').value = state.config.progressRingColor || '#00f0ff';
document.getElementById('progressRingWidth').value = state.config.progressRingWidth || 6;
document.getElementById('rotationSpeed').value = state.config.rotationSpeed ?? 3;
document.getElementById('beatSpinImpulse').checked = state.config.beatSpinImpulse !== false;
document.getElementById('showSpectrum').checked = state.config.showSpectrum !== false;
const modeRadio = document.querySelector(`input[name="spectrumMode"][value="${state.config.spectrumMode || 'radial_bars'}"]`);
if (modeRadio) modeRadio.checked = true;
document.getElementById('linearAlignment').value = state.config.linearAlignment || 'center_mirrored';
document.getElementById('barCount').value = state.config.barCount || 96;
document.getElementById('spectrumMaxHeight').value = state.config.spectrumMaxHeight || 130;
document.getElementById('barWidth').value = state.config.barWidth || 4;
document.getElementById('barSpacing').value = state.config.barSpacing || 12;
document.getElementById('barCornerRadius').value = state.config.barCornerRadius || 2;
document.getElementById('freqSensitivity').value = state.config.freqSensitivity || 1.3;
document.getElementById('freqFocus').value = state.config.freqFocus || 'balanced';
document.getElementById('mirrorSpectrum').checked = state.config.mirrorSpectrum !== false;
document.getElementById('enableParticles').checked = state.config.enableParticles !== false;
document.getElementById('particleType').value = state.config.particleType || 'starfield';
document.getElementById('particleCount').value = state.config.particleCount || 100;
document.getElementById('particleSpeed').value = state.config.particleSpeed || 1.0;
document.getElementById('particleWarp').value = state.config.particleWarp || 2.5;
document.getElementById('enableGrid').checked = !!state.config.enableGrid;
document.getElementById('colorGrid').value = state.config.colorGrid || '#ff007f';
document.getElementById('gridPerspective').value = state.config.gridPerspective || 0.65;
document.getElementById('gridAudioReactive').value = state.config.gridAudioReactive || 0.5;
document.getElementById('enableShake').checked = state.config.enableShake !== false;
document.getElementById('camShakeIntensity').value = state.config.camShakeIntensity || 0.35;
document.getElementById('enableGlitch').checked = state.config.enableGlitch !== false;
document.getElementById('rgbSplitIntensity').value = state.config.rgbSplitIntensity || 0.25;
document.getElementById('beatFlash').checked = !!state.config.beatFlash;
document.getElementById('enableBassZoom').checked = state.config.enableBassZoom !== false;
document.getElementById('colorMode').value = state.config.colorMode || 'gradient_2';
document.getElementById('colorGrad1').value = state.config.colorGrad1 || '#00f0ff';
document.getElementById('colorGrad2').value = state.config.colorGrad2 || '#ff007f';
document.getElementById('colorGrad3').value = state.config.colorGrad3 || '#7000ff';
document.getElementById('colorParticle').value = state.config.colorParticle || '#ffffff';
document.getElementById('gradientStyle').value = state.config.gradientStyle || 'angular';
document.getElementById('enableGlow').checked = state.config.enableGlow !== false;
document.getElementById('glowIntensity').value = state.config.glowIntensity || 0.75;
document.getElementById('glowBlur').value = state.config.glowBlur || 25;
document.getElementById('laserCore').checked = !!state.config.laserCore;
document.getElementById('backgroundMode').value = state.config.backgroundMode || 'transparent';
document.getElementById('showTextOverlay').checked = state.config.showTextOverlay !== false;
document.getElementById('fontFamily').value = state.config.fontFamily || "'Outfit', sans-serif";
document.getElementById('textPosition').value = state.config.textPosition || 'below_circle';
document.getElementById('textOffsetY').value = state.config.textOffsetY || 0;
document.getElementById('textSpacing').value = state.config.textSpacing ?? 10;
document.getElementById('textPulseWithBeat').checked = state.config.textPulseWithBeat !== false;
document.getElementById('textGlow').checked = !!state.config.textGlow;
document.getElementById('showTrackTitle').checked = state.config.showTrackTitle !== false;
document.getElementById('titleFontSize').value = state.config.titleFontSize || 38;
document.getElementById('titleColor').value = state.config.titleColor || '#ffffff';
document.getElementById('titleFontWeight').value = state.config.titleFontWeight || '700';
document.getElementById('titleUppercase').checked = !!state.config.titleUppercase;
document.getElementById('showTrackArtist').checked = state.config.showTrackArtist !== false;
document.getElementById('artistFontSize').value = state.config.artistFontSize || 22;
document.getElementById('artistColor').value = state.config.artistColor || '#cccccc';
document.getElementById('artistFontWeight').value = state.config.artistFontWeight || '500';
document.getElementById('artistUppercase').checked = !!state.config.artistUppercase;
document.getElementById('showTimecode').checked = state.config.showTimecode !== false;
document.getElementById('timecodeFontSize').value = state.config.timecodeFontSize || 16;
document.getElementById('timecodeColor').value = state.config.timecodeColor || '#00f0ff';
document.getElementById('timecodeMode').value = state.config.timecodeMode || 'elapsed_and_total';
document.getElementById('showProgressBar').checked = !!state.config.showProgressBar;
document.getElementById('progressBarWidth').value = state.config.progressBarWidth || 380;
document.getElementById('progressBarHeight').value = state.config.progressBarHeight || 4;
document.getElementById('progressBarColor').value = state.config.progressBarColor || '#00f0ff';
updateConfigFromUI();
}
// --- Aspect Ratio Switching ---
function setAspectRatio(aspect) {
state.aspectRatio = aspect;
aspectBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.aspect === aspect);
});
canvasWrapper.className = 'canvas-wrapper';
if (toggleAlphaGrid.checked) canvasWrapper.classList.add('alpha-bg');
if (aspect === '16:9') {
canvasWrapper.classList.add('aspect-16-9');
state.width = 1920;
state.height = 1080;
} else if (aspect === '9:16') {
canvasWrapper.classList.add('aspect-9-16');
state.width = 1080;
state.height = 1920;
} else if (aspect === '1:1') {
canvasWrapper.classList.add('aspect-1-1');
state.width = 1080;
state.height = 1080;
}
canvas.width = state.width;
canvas.height = state.height;
initParticles(state.config.particleCount);
}
// --- Video Export Engine (Deterministic Frame-by-Frame FFmpeg Pipeline) ---
let currentExportSessionId = null;
let isExportCancelled = false;
function audioBufferToWavBlob(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
let result;
if (numChannels === 2) {
const left = buffer.getChannelData(0);
const right = buffer.getChannelData(1);
const len = left.length + right.length;
result = new Float32Array(len);
for (let i = 0; i < left.length; i++) {
result[i * 2] = left[i];
result[i * 2 + 1] = right[i];
}
} else {
result = buffer.getChannelData(0);
}
const bytesPerSample = bitDepth / 8;
const blockAlign = numChannels * bytesPerSample;
const wavBuffer = new ArrayBuffer(44 + result.length * bytesPerSample);
const view = new DataView(wavBuffer);
function writeStr(offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
writeStr(0, 'RIFF');
view.setUint32(4, 36 + result.length * bytesPerSample, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeStr(36, 'data');
view.setUint32(40, result.length * bytesPerSample, true);
let offset = 44;
for (let i = 0; i < result.length; i++) {
let s = Math.max(-1, Math.min(1, result[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
view.setInt16(offset, s, true);
offset += 2;
}
return new Blob([view], { type: 'audio/wav' });
}
function updateExportEngineBadge() {
const formatSelect = document.getElementById('exportFormat');
if (!formatSelect) return;
const format = formatSelect.value;
const badge = document.getElementById('exportEngineBadge');
const title = document.getElementById('exportEngineTitle');
const desc = document.getElementById('exportEngineDesc');
const icon = badge ? badge.querySelector('.badge-icon') : null;
if (!badge || !title || !desc) return;
if (format === 'webm_alpha' || format === 'mp4') {
badge.classList.remove('badge-server');
if (icon) icon.textContent = '⚡';
title.textContent = 'Client-GPU Export (Blitzschnell im Browser)';
if (format === 'webm_alpha') {
desc.textContent = 'Rendert direkt im Browser bei 60–120+ FPS mit transparentem Alphakanal für DaVinci Resolve (0 MB Server-Upload).';
} else {
desc.textContent = 'Rendert direkt im Browser bei 60–120+ FPS als H.264 MP4 für YouTube / Social Media (0 MB Server-Upload).';
}
} else {
badge.classList.add('badge-server');
if (icon) icon.textContent = '⚡🖥️';
title.textContent = 'Studio Master Hybrid-Export (GPU + Server FFmpeg)';
if (format === 'prores') {
desc.textContent = 'Rendert mit GPU-Beschleunigung und wandelt auf dem Server via FFmpeg in echtes Apple ProRes 4444 (10-Bit Alpha) um – in unter 2 Minuten!';
} else {
desc.textContent = 'Rendert mit GPU-Beschleunigung und wandelt auf dem Server via FFmpeg in verlustfreies QuickTime RLE um.';
}
}
}
/**
* Probes for the best supported WebCodecs Video configuration
* Checks Hardware acceleration first, falls back to Software and alternative codec profiles
*/
async function findBestSupportedVideoConfig(format, width, height, fps, bitrate) {
const isWebM = (format === 'webm_alpha');
const configsToTest = [];
if (isWebM) {
// 1. VP9 with Alpha (Hardware preference)
configsToTest.push(
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep', hardwareAcceleration: 'prefer-hardware' },
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep', hardwareAcceleration: 'prefer-software' },
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, alpha: 'keep' },
// 2. VP9 without Alpha (Fallback if GPU doesn't support alpha in VP9)
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
{ codec: 'vp09.00.10.08', width, height, bitrate, framerate: fps },
// 3. VP8 Fallback
{ codec: 'vp8', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
{ codec: 'vp8', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
{ codec: 'vp8', width, height, bitrate, framerate: fps }
);
} else {
// MP4 / H.264 configurations (High, Main, Baseline profiles)
configsToTest.push(
{ codec: 'avc1.64002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
{ codec: 'avc1.64002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
{ codec: 'avc1.4d002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
{ codec: 'avc1.4d002a', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
{ codec: 'avc1.42001f', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
{ codec: 'avc1.42001f', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
{ codec: 'avc1.42001e', width, height, bitrate, framerate: fps }
);
}
for (const cfg of configsToTest) {
try {
const check = await VideoEncoder.isConfigSupported(cfg);
if (check && check.supported) {
console.log('✅ WebCodecs selected config:', check.config || cfg);
return check.config || cfg;
}
} catch (e) {
// Continue searching
}
}
return null;
}
/**
* Resamples an AudioBuffer to target sample rate using OfflineAudioContext (lossless sinc interpolation)
*/
async function resampleAudioBuffer(sourceBuffer, targetSampleRate) {
if (!sourceBuffer || sourceBuffer.sampleRate === targetSampleRate) {
return sourceBuffer;
}
const numChannels = Math.min(2, sourceBuffer.numberOfChannels);
const targetLength = Math.max(1, Math.ceil(sourceBuffer.duration * targetSampleRate));
const offlineCtx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(
numChannels,
targetLength,
targetSampleRate
);
const bufferSource = offlineCtx.createBufferSource();
bufferSource.buffer = sourceBuffer;
bufferSource.connect(offlineCtx.destination);
bufferSource.start(0);
return await offlineCtx.startRendering();
}
/**
* Hardware-Accelerated In-Browser Video Export Engine (WebCodecs + Muxer)
* Supports: WebM VP9 Alpha & MP4 H.264 with robust codec probing & lossless audio
*/
async function exportVideoClientSide(format, targetWidth, targetHeight, fps, title, options = {}) {
const isWebM = (format === 'webm_alpha');
const ext = isWebM ? 'webm' : 'mp4';
const suffix = isWebM ? 'Alpha_DaVinci' : 'Visualizer';
const outputFilename = `${title}_${suffix}.${ext}`;
const totalFrames = Math.ceil(state.duration * fps);
const sampleRate = state.audioBuffer.sampleRate;
const numChannels = Math.min(2, state.audioBuffer.numberOfChannels);
const channelData = state.audioBuffer.getChannelData(0);
const sampleWindow = 2048;
const samplesBuffer = new Float32Array(sampleWindow);
// Create Offscreen Canvas for Frame Rendering
const renderCanvas = document.createElement('canvas');
renderCanvas.width = targetWidth;
renderCanvas.height = targetHeight;
const renderCtx = renderCanvas.getContext('2d', { alpha: true, willReadFrequently: true });
// Determine Bitrate based on Resolution & Mode (High bitrates prevent alpha macroblocking)
let videoBitrate = 35_000_000; // 35 Mbps default
if (options && options.returnBlobOnly) {
videoBitrate = (targetWidth >= 3840) ? 60_000_000 : (targetWidth <= 1280 ? 15_000_000 : 28_000_000);
} else {
if (targetWidth >= 3840) videoBitrate = 80_000_000;
else if (targetWidth <= 1280) videoBitrate = 20_000_000;
else videoBitrate = 45_000_000;
}
// Verify WebCodecs support
const hasWebCodecs = (typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined');
if (!hasWebCodecs) {
throw new Error('Dein Browser unterstützt die WebCodecs-Schnittstelle nicht für Offline-Rendering. Bitte verwende Google Chrome, Microsoft Edge oder einen aktuellen Chromium-Browser.');
}
// 1. Probe & find supported VideoEncoder configuration
const chosenVideoConfig = await findBestSupportedVideoConfig(format, targetWidth, targetHeight, fps, videoBitrate);
if (!chosenVideoConfig) {
throw new Error(`Kein kompatibler Video-Codec für Format "${format}" auf diesem Gerät gefunden.`);
}
const supportsAlpha = isWebM && (chosenVideoConfig.alpha === 'keep' || (chosenVideoConfig.codec && chosenVideoConfig.codec.startsWith('vp09')));
let muxer = null;
let videoEncoder = null;
let audioEncoder = null;
let hasAudioTrack = false;
let encoderError = null;
let chosenAudioConfig = null;
// 2. Configure Muxer & Audio Encoder
if (isWebM) {
if (typeof WebMMuxer === 'undefined') {
throw new Error('WebMMuxer-Bibliothek wurde nicht geladen.');
}
if (typeof AudioEncoder !== 'undefined') {
const audioTests = [
{ codec: 'opus', sampleRate: 48000, numberOfChannels: numChannels, bitrate: 320000 },
{ codec: 'opus', sampleRate: sampleRate, numberOfChannels: numChannels, bitrate: 320000 }
];
for (const aCfg of audioTests) {
try {
const supported = await AudioEncoder.isConfigSupported(aCfg);
if (supported && supported.supported) {
chosenAudioConfig = supported.config || aCfg;
break;
}
} catch (e) {}
}
}
const muxerCodec = (chosenVideoConfig.codec && chosenVideoConfig.codec.startsWith('vp8')) ? 'V_VP8' : 'V_VP9';
muxer = new WebMMuxer.Muxer({
target: new WebMMuxer.ArrayBufferTarget(),
video: {
codec: muxerCodec,
width: targetWidth,
height: targetHeight,
alpha: supportsAlpha,
frameRate: fps
},
audio: chosenAudioConfig ? {
codec: 'A_OPUS',
numberOfChannels: chosenAudioConfig.numberOfChannels,
sampleRate: chosenAudioConfig.sampleRate,
bitrate: 320000
} : undefined
});
if (chosenAudioConfig) {
try {
audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: (e) => {
console.error('AudioEncoder Error:', e);
encoderError = e;
}
});
audioEncoder.configure(chosenAudioConfig);
hasAudioTrack = true;
} catch (e) {
console.error('AudioEncoder configure failed:', e);
audioEncoder = null;
hasAudioTrack = false;
}
}
} else {
// MP4 Muxer
if (typeof Mp4Muxer === 'undefined') {
throw new Error('Mp4Muxer-Bibliothek wurde nicht geladen.');
}
if (typeof AudioEncoder !== 'undefined') {
const audioTests = [
{ codec: 'mp4a.40.2', sampleRate: sampleRate, numberOfChannels: numChannels, bitrate: 320000 },
{ codec: 'mp4a.40.2', sampleRate: 48000, numberOfChannels: numChannels, bitrate: 320000 },
{ codec: 'mp4a.40.2', sampleRate: 44100, numberOfChannels: numChannels, bitrate: 320000 }
];
for (const aCfg of audioTests) {
try {
const supported = await AudioEncoder.isConfigSupported(aCfg);
if (supported && supported.supported) {
chosenAudioConfig = supported.config || aCfg;
break;
}
} catch (e) {}
}
}
muxer = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
video: {
codec: 'avc',
width: targetWidth,
height: targetHeight
},
audio: chosenAudioConfig ? {
codec: 'aac',
numberOfChannels: chosenAudioConfig.numberOfChannels,
sampleRate: chosenAudioConfig.sampleRate
} : undefined,
fastStart: 'in-memory'
});
if (chosenAudioConfig) {
try {
audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: (e) => {
console.error('AAC AudioEncoder Error:', e);
encoderError = e;
}
});
audioEncoder.configure(chosenAudioConfig);
hasAudioTrack = true;
} catch (e) {
console.error('AAC AudioEncoder configure failed:', e);
audioEncoder = null;
hasAudioTrack = false;
}
}
}
// 3. Instantiate & Configure VideoEncoder
videoEncoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => {
console.error('VideoEncoder Runtime Error:', e);
encoderError = e;
}
});
videoEncoder.configure(chosenVideoConfig);
// 4. Encode Audio track synchronously with exact sample-rate matching
if (audioEncoder && hasAudioTrack && audioEncoder.state === 'configured' && chosenAudioConfig) {
try {
exportStatusText.textContent = 'Encodiere Audio-Track in Studio-Qualität...';
const targetSampleRate = chosenAudioConfig.sampleRate || 48000;
// Resample AudioBuffer if needed (e.g. 44.1kHz -> 48kHz for Opus)
const processedAudioBuffer = await resampleAudioBuffer(state.audioBuffer, targetSampleRate);
const totalAudioSamples = processedAudioBuffer.length;
const channels = chosenAudioConfig.numberOfChannels;
const chunkSize = 4096;
for (let offset = 0; offset < totalAudioSamples; offset += chunkSize) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
const currentChunkSize = Math.min(chunkSize, totalAudioSamples - offset);
const chunkData = new Float32Array(currentChunkSize * channels);
for (let c = 0; c < channels; c++) {
const srcData = processedAudioBuffer.getChannelData(c);
for (let i = 0; i < currentChunkSize; i++) {
chunkData[c * currentChunkSize + i] = srcData[offset + i];
}
}
const timestampUs = Math.round((offset / targetSampleRate) * 1_000_000);
const audioData = new AudioData({
format: 'f32-planar',
sampleRate: targetSampleRate,
numberOfChannels: channels,
numberOfFrames: currentChunkSize,
timestamp: timestampUs,
data: chunkData
});
audioEncoder.encode(audioData);
audioData.close();
}
// Flush audio encoder to ensure all chunks are written into the muxer
await audioEncoder.flush();
console.log(`✅ Audio track successfully encoded & muxed (${totalAudioSamples} samples @ ${targetSampleRate}Hz)`);
} catch (aErr) {
console.error('Audio encoding failed:', aErr);
throw new Error(`Fehler beim Audio-Encoding: ${aErr.message}`);
}
}
// 5. Offline Frame-by-Frame Video Render Loop
const renderStartTime = performance.now();
let lastEtaUpdate = 0;
for (let f = 0; f < totalFrames; f++) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
if (encoderError) {
throw new Error(`VideoEncoder Fehler: ${encoderError.message || encoderError}`);
}
if (videoEncoder.state !== 'configured') {
throw new Error(`VideoEncoder befindet sich im Status "${videoEncoder.state}". Möglicherweise unterstützt die Grafikkarte diesen Codec nicht.`);
}
const currentTime = f / fps;
const centerSample = Math.floor(currentTime * sampleRate);
const startSample = Math.max(0, centerSample - 1024);
for (let s = 0; s < sampleWindow; s++) {
const idx = startSample + s;
samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0;
}
const frameFreq = globalFastFFT.compute(samplesBuffer, 1024);
// Render frame to offscreen canvas
renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true);
// Create VideoFrame for WebCodecs
const timestampUs = Math.round((f / fps) * 1_000_000);
const durationUs = Math.round((1 / fps) * 1_000_000);
const isKeyFrame = (f % (fps * 2) === 0);
const videoFrame = new VideoFrame(renderCanvas, {
timestamp: timestampUs,
duration: durationUs,
alpha: supportsAlpha ? 'keep' : 'discard'
});
try {
videoEncoder.encode(videoFrame, { keyFrame: isKeyFrame });
} finally {
videoFrame.close();
}
// Backpressure management (prevent encoder queue from filling RAM)
if (videoEncoder.encodeQueueSize > 6) {
await new Promise(resolve => {
videoEncoder.ondequeue = () => {
if (videoEncoder.encodeQueueSize <= 2) {
videoEncoder.ondequeue = null;
resolve();
}
};
});
}
// Live Progress & FPS Display
const pct = Math.round(((f + 1) / totalFrames) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
exportStatusText.textContent = `Rendert Frame ${f + 1} / ${totalFrames} (${pct}%)`;
const now = performance.now();
if (now - lastEtaUpdate > 250 || f === totalFrames - 1) {
const elapsedSecs = (now - renderStartTime) / 1000;
const currentFps = (f + 1) / (elapsedSecs || 0.001);
const remainingFrames = totalFrames - f - 1;
const remainingSecs = remainingFrames / (currentFps || 1);
if (exportFpsLive) exportFpsLive.textContent = `Geschwindigkeit: ${currentFps.toFixed(1)} fps (GPU)`;
exportEta.textContent = `Verbleibend: ~${formatTime(remainingSecs)}`;
lastEtaUpdate = now;
}
// UI thread breathing room
if (f % 6 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
// 6. Finalize Video & Audio Containers
exportStatusText.textContent = 'Finalisiere Video-Container...';
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Metadaten...';
if (encoderError) {
throw new Error(`VideoEncoder Fehler vor Finalisierung: ${encoderError.message || encoderError}`);
}
if (videoEncoder.state === 'configured') {
await videoEncoder.flush();
}
if (audioEncoder && audioEncoder.state === 'configured') {
await audioEncoder.flush();
}
muxer.finalize();
const { buffer } = muxer.target;
const mimeType = isWebM ? 'video/webm' : 'video/mp4';
const blob = new Blob([buffer], { type: mimeType });
const elapsedTotal = ((performance.now() - renderStartTime) / 1000).toFixed(1);
const avgFps = (totalFrames / ((performance.now() - renderStartTime) / 1000)).toFixed(1);
const sizeMb = (blob.size / 1024 / 1024).toFixed(1);
console.log(`✅ Client Render Complete in ${elapsedTotal}s @ ${avgFps} fps (${sizeMb} MB)`);
if (options.returnBlobOnly) {
return { blob, outputFilename, avgFps, sizeMb };
}
// Download file
const downloadUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.style.display = 'none';
downloadLink.href = downloadUrl;
downloadLink.download = outputFilename;
document.body.appendChild(downloadLink);
downloadLink.click();
setTimeout(() => {
document.body.removeChild(downloadLink);
URL.revokeObjectURL(downloadUrl);
}, 5000);
alert(`🎉 Fertig! Dein Video "${outputFilename}" (${sizeMb} MB) wurde mit durchschnittlich ${avgFps} fps gerendert und heruntergeladen.`);
}
/**
* High-Speed Studio Master Server-Side Transcoding Engine
* Utilizes client-side GPU rasterization + server-side FFmpeg 10-bit ProRes / QTRLE mastering
* Renders at 60-120+ FPS instead of 0.2 FPS!
*/
async function exportVideoServerSide(format, targetWidth, targetHeight, fps, title) {
const hasWebCodecs = (typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined');
if (hasWebCodecs) {
// Step 1: Render intermediate mezzanine video at 60-120+ FPS locally on GPU
const mezzanineFormat = (format === 'prores' || format === 'qtrle' || format === 'webm_alpha') ? 'webm_alpha' : 'mp4';
exportStatusText.textContent = `Rendere Grafik-Layer mit GPU-Beschleunigung...`;
const renderRes = await exportVideoClientSide(mezzanineFormat, targetWidth, targetHeight, fps, title, { returnBlobOnly: true });
if (!renderRes || !renderRes.blob) {
throw new Error('Fehler beim GPU-Rendering des Zwischenvideos');
}
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
// Step 2: Upload mezzanine video + original audio to Server FFmpeg Transcoder
exportStatusText.textContent = 'Übertrage Video an Server FFmpeg Transcoder (0%)...';
if (exportFpsLive) exportFpsLive.textContent = 'Upload zum Server...';
exportProgressBarFill.style.width = '0%';
exportPercent.textContent = '0%';
let audioBlob = state.audioFile;
if (!audioBlob) {
audioBlob = audioBufferToWavBlob(state.audioBuffer);
}
const formData = new FormData();
formData.append('video', renderRes.blob, renderRes.outputFilename);
formData.append('audio', audioBlob, state.audioFile ? state.audioFile.name : 'audio.wav');
formData.append('format', format);
formData.append('width', targetWidth);
formData.append('height', targetHeight);
formData.append('fps', fps);
formData.append('title', title);
const transcodeStartRes = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/export/transcode');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
const loadedMb = (e.loaded / 1024 / 1024).toFixed(1);
const totalMb = (e.total / 1024 / 1024).toFixed(1);
exportStatusText.textContent = `Übertrage Video an Server (${loadedMb} / ${totalMb} MB - ${pct}%)...`;
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (err) {
reject(new Error('Ungültige Server-Antwort'));
}
} else {
let errMsg = `Server antwortete mit Status ${xhr.status}`;
if (xhr.status === 408) {
errMsg = 'Server Timeout (408): Die Verbindung zum Server wurde unterbrochen. Bitte stelle sicher, dass der Server mit den neuesten Updates läuft oder wähle "WebM VP9 Alpha" für direkten 120 FPS Export ohne Server-Upload.';
}
try {
const errData = JSON.parse(xhr.responseText);
if (errData.error) errMsg = errData.error;
} catch (e) {}
reject(new Error(errMsg));
}
};
xhr.onerror = () => reject(new Error('Netzwerkfehler beim Upload zum Server'));
xhr.send(formData);
});
currentExportSessionId = transcodeStartRes.sessionId;
// Step 3: Monitor Server FFmpeg Transcode
exportStatusText.textContent = `Server transcodiert Master Video...`;
if (exportFpsLive) exportFpsLive.textContent = 'FFmpeg Multi-Thread Encoding...';
await monitorServerSession(currentExportSessionId, format);
return;
}
// Direct Frame-Streaming Fallback (Legacy browsers without WebCodecs)
let audioBlob = state.audioFile;
if (!audioBlob) {
audioBlob = audioBufferToWavBlob(state.audioBuffer);
}
const formData = new FormData();
formData.append('audio', audioBlob, state.audioFile ? state.audioFile.name : 'audio.wav');
formData.append('format', format);
formData.append('width', targetWidth);
formData.append('height', targetHeight);
formData.append('fps', fps);
formData.append('title', title);
exportStatusText.textContent = 'Verbinde mit Server FFmpeg Transcoder...';
const startRes = await fetch('/api/export/start', {
method: 'POST',
body: formData
});
if (!startRes.ok) {
const errJson = await startRes.json().catch(() => ({}));
throw new Error(errJson.error || `Server antwortete mit Status ${startRes.status}`);
}
const startData = await startRes.json();
currentExportSessionId = startData.sessionId;
// Offscreen Canvas for Rendering
const renderCanvas = document.createElement('canvas');
renderCanvas.width = targetWidth;
renderCanvas.height = targetHeight;
const renderCtx = renderCanvas.getContext('2d', { alpha: true, willReadFrequently: true });
const totalFrames = Math.ceil(state.duration * fps);
const sampleRate = state.audioBuffer.sampleRate;
const channelData = state.audioBuffer.getChannelData(0);
const sampleWindow = 2048;
const samplesBuffer = new Float32Array(sampleWindow);
const renderStartTime = performance.now();
let lastEtaUpdate = 0;
// Batching: Concatenate 15 frames into one network payload
const batchFrameCount = 15;
const singleFrameBytes = targetWidth * targetHeight * 4;
const batchBuffer = new Uint8Array(singleFrameBytes * batchFrameCount);
let framesInBatch = 0;
for (let f = 0; f < totalFrames; f++) {
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
const currentTime = f / fps;
const centerSample = Math.floor(currentTime * sampleRate);
const startSample = Math.max(0, centerSample - 1024);
for (let s = 0; s < sampleWindow; s++) {
const idx = startSample + s;
samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0;
}
const frameFreq = globalFastFFT.compute(samplesBuffer, 1024);
// Render high-precision frame
renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true);
// Extract raw RGBA
const imgData = renderCtx.getImageData(0, 0, targetWidth, targetHeight);
batchBuffer.set(imgData.data, framesInBatch * singleFrameBytes);
framesInBatch++;
// Send batch when full or on last frame
if (framesInBatch === batchFrameCount || f === totalFrames - 1) {
const payload = (framesInBatch === batchFrameCount)
? batchBuffer.buffer
: batchBuffer.slice(0, framesInBatch * singleFrameBytes).buffer;
const frameRes = await fetch(`/api/export/frame-batch/${currentExportSessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: payload
});
if (!frameRes.ok) {
const frameErr = await frameRes.json().catch(() => ({}));
throw new Error(frameErr.error || 'Fehler beim Senden des Frame-Batches an Server FFmpeg');
}
framesInBatch = 0;
}
// Live Progress & ETA
const pct = Math.round(((f + 1) / totalFrames) * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
exportStatusText.textContent = `Rendert Frame ${f + 1} / ${totalFrames} (${pct}%)`;
const now = performance.now();
if (now - lastEtaUpdate > 400 || f === totalFrames - 1) {
const elapsedSecs = (now - renderStartTime) / 1000;
const currentFps = (f + 1) / elapsedSecs;
const remainingFrames = totalFrames - f - 1;
const remainingSecs = remainingFrames / (currentFps || 1);
if (exportFpsLive) exportFpsLive.textContent = `Geschwindigkeit: ${currentFps.toFixed(1)} fps (Server)`;
exportEta.textContent = `Verbleibend: ~${formatTime(remainingSecs)}`;
lastEtaUpdate = now;
}
if (f % 6 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
// Finish Server Encoding
exportStatusText.textContent = 'Finalisiere Video mit Studio-Codec auf dem Server...';
if (exportFpsLive) exportFpsLive.textContent = 'Schreibe Audio & Video...';
const finishRes = await fetch(`/api/export/finish/${currentExportSessionId}`, {
method: 'POST'
});
if (!finishRes.ok) {
const finishErr = await finishRes.json().catch(() => ({}));
throw new Error(finishErr.error || 'Fehler beim Finalisieren des Videos auf dem Server');
}
// Poll status until ready
await monitorServerSession(currentExportSessionId, format);
}
async function monitorServerSession(sessionId, format) {
let finishData = null;
while (!finishData && !isExportCancelled) {
await new Promise(r => setTimeout(r, 1000));
const statusRes = await fetch(`/api/export/status/${sessionId}`);
if (!statusRes.ok) {
const errData = await statusRes.json().catch(() => ({}));
throw new Error(errData.error || 'Fehler beim Abrufen des Video-Status');
}
const statusJson = await statusRes.json();
if (statusJson.status === 'ready') {
finishData = statusJson;
break;
} else if (statusJson.status === 'error') {
throw new Error(statusJson.error || 'FFmpeg Encoder-Fehler auf dem Server');
} else {
const mb = (statusJson.sizeBytes / 1024 / 1024).toFixed(1);
exportStatusText.textContent = `Server verarbeitet Render-Job (${mb} MB geschrieben)...`;
if (exportFpsLive) exportFpsLive.textContent = 'Warten auf FFmpeg...';
}
}
if (isExportCancelled) throw new Error('Export durch Benutzer abgebrochen');
fetchRenderHistory(); // Refresh the UI list
alert(`🎉 Fertig! Das Rendering ist abgeschlossen. Wähle nun im Render-Verlauf dein gewünschtes Format zum Herunterladen aus.`);
}
async function startExport() {
if (!state.audioLoaded || !state.audioBuffer) {
alert('Bitte lade zuerst eine Audio-Datei (.wav oder .mp3) im Tab "Media" hoch!');
document.querySelector('[data-tab="tab-media"]').click();
return;
}
const format = document.getElementById('exportFormat').value;
const res = document.getElementById('exportResolution').value;
const fps = parseInt(document.getElementById('exportFps').value, 10) || 60;
let targetWidth = 1920;
let targetHeight = 1080;
if (state.aspectRatio === '9:16') {
targetWidth = 1080;
targetHeight = 1920;
} else if (state.aspectRatio === '1:1') {
targetWidth = 1080;
targetHeight = 1080;
}
if (res === '4k') {
targetWidth *= 2;
targetHeight *= 2;
} else if (res === '720p') {
targetWidth = Math.round(targetWidth * 0.667);
targetHeight = Math.round(targetHeight * 0.667);
}
// Force strictly even dimensions (divisible by 2) for encoder compatibility
targetWidth = Math.floor(targetWidth / 2) * 2;
targetHeight = Math.floor(targetHeight / 2) * 2;
pausePlayback();
state.isExporting = true;
isExportCancelled = false;
exportProgressCard.classList.remove('hidden');
btnStartExport.classList.add('hidden');
exportStatusText.textContent = 'Bereite Render vor...';
exportPercent.textContent = '0%';
exportProgressBarFill.style.width = '0%';
exportEta.textContent = `Gesamtlänge: ${formatTime(state.duration)}`;
const exportFpsLive = document.getElementById('exportFpsLive');
if (exportFpsLive) exportFpsLive.textContent = 'Status: Initialisiere Engine...';
const title = (document.getElementById('trackTitle').value || 'Visualizer').trim();
try {
if (format === 'webm_alpha' || format === 'mp4') {
try {
await exportVideoClientSide(format, targetWidth, targetHeight, fps, title);
} catch (clientErr) {
console.warn('Client-GPU export failed on this device, checking fallback:', clientErr);
if (isExportCancelled) throw clientErr;
const useFallback = confirm(
`Der hardwarebeschleunigte Browser-Encoder meldet auf diesem Gerät einen Codec-Fehler:\n"${clientErr.message}"\n\nMöchtest du dieses Video stattdessen über den Server-Encoder (FFmpeg) rendern?`
);
if (useFallback) {
const fallbackFormat = (format === 'webm_alpha') ? 'prores' : 'mp4';
await exportVideoServerSide(fallbackFormat, targetWidth, targetHeight, fps, title);
} else {
throw clientErr;
}
}
} else {
await exportVideoServerSide(format, targetWidth, targetHeight, fps, title);
}
} catch (err) {
console.error('Export Engine Error:', err);
if (!isExportCancelled) {
alert('Fehler beim Video-Export: ' + err.message);
}
} finally {
state.isExporting = false;
currentExportSessionId = null;
exportProgressCard.classList.add('hidden');
btnStartExport.classList.remove('hidden');
}
}
async function cancelExport() {
isExportCancelled = true;
state.isExporting = false;
if (currentExportSessionId) {
try {
await fetch(`/api/export/cancel/${currentExportSessionId}`, { method: 'POST' });
} catch (e) {}
currentExportSessionId = null;
}
exportProgressCard.classList.add('hidden');
btnStartExport.classList.remove('hidden');
}
// --- Event Listeners & Binding ---
function setupEvents() {
audioDropzone.addEventListener('click', () => audioFileInput.click());
imageDropzone.addEventListener('click', () => imageFileInput.click());
audioFileInput.addEventListener('change', (e) => {
if (e.target.files.length) loadAudioFile(e.target.files[0]);
});
imageFileInput.addEventListener('change', (e) => {
if (e.target.files.length) loadImageFile(e.target.files[0]);
});
[audioDropzone, imageDropzone].forEach(dz => {
dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', (e) => {
e.preventDefault();
dz.classList.remove('dragover');
if (dz === audioDropzone && e.dataTransfer.files.length) {
loadAudioFile(e.dataTransfer.files[0]);
} else if (dz === imageDropzone && e.dataTransfer.files.length) {
loadImageFile(e.dataTransfer.files[0]);
}
});
});
btnPlayPause.addEventListener('click', () => {
if (state.isPlaying) {
pausePlayback();
} else {
startPlayback(state.pauseOffset || 0);
}
});
btnRewind.addEventListener('click', () => {
const cur = getCurrentPlaybackTime();
const target = Math.max(0, cur - 5);
seekScrubber.value = target;
currentTimeEl.textContent = formatTime(target);
const pct = (target / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(target);
} else {
state.pauseOffset = target;
}
});
btnForward.addEventListener('click', () => {
const cur = getCurrentPlaybackTime();
const target = Math.min(state.duration || 0, cur + 5);
seekScrubber.value = target;
currentTimeEl.textContent = formatTime(target);
const pct = (target / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(target);
} else {
state.pauseOffset = target;
}
});
btnLoop.addEventListener('click', () => {
state.loop = !state.loop;
btnLoop.classList.toggle('active', state.loop);
if (state.audioSource) state.audioSource.loop = state.loop;
});
seekScrubber.addEventListener('input', (e) => {
const t = parseFloat(e.target.value);
currentTimeEl.textContent = formatTime(t);
const pct = (t / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(t);
} else {
state.pauseOffset = t;
}
});
volumeSlider.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (state.gainNode) state.gainNode.gain.value = v;
});
aspectBtns.forEach(btn => {
btn.addEventListener('click', () => setAspectRatio(btn.dataset.aspect));
});
toggleSafeZone.addEventListener('change', (e) => {
safeZoneOverlay.classList.toggle('hidden', !e.target.checked);
});
toggleAlphaGrid.addEventListener('change', (e) => {
canvasWrapper.classList.toggle('alpha-bg', e.target.checked);
});
quickPresetSelect.addEventListener('change', (e) => {
applyPreset(e.target.value);
});
// AIWentNonsense 1-Click Mode Trigger
const btnAiWentNonsenseMode = document.getElementById('btnAiWentNonsenseMode');
if (btnAiWentNonsenseMode) {
btnAiWentNonsenseMode.addEventListener('click', () => {
applyPreset('aiwentnonsense');
if (quickPresetSelect) quickPresetSelect.value = 'aiwentnonsense';
const artistInput = document.getElementById('trackArtist');
if (artistInput && (artistInput.value === 'Artist Name' || artistInput.value === '')) {
artistInput.value = 'AIWentNonsense';
state.config.trackArtist = 'AIWentNonsense';
}
});
}
// AIWentNonsense Cover Loader
const btnLoadAiWentNonsenseCover = document.getElementById('btnLoadAiWentNonsenseCover');
if (btnLoadAiWentNonsenseCover) {
btnLoadAiWentNonsenseCover.addEventListener('click', () => {
loadWasserzeichenLogo(() => {
const showCoverCb = document.getElementById('showCover');
if (showCoverCb) {
showCoverCb.checked = true;
state.config.showCover = true;
}
});
});
}
// Preset Library Cards
document.querySelectorAll('.preset-select-btn').forEach(btn => {
btn.addEventListener('click', () => {
applyPreset(btn.dataset.preset);
quickPresetSelect.value = btn.dataset.preset;
});
});
// 1-Click Palette Chips
document.querySelectorAll('.palette-chip').forEach(chip => {
chip.addEventListener('click', () => {
document.getElementById('colorGrad1').value = chip.dataset.p1;
document.getElementById('colorGrad2').value = chip.dataset.p2;
document.getElementById('colorGrad3').value = chip.dataset.p3;
document.getElementById('colorParticle').value = chip.dataset.p1;
document.getElementById('colorGrid').value = chip.dataset.p2;
updateConfigFromUI();
});
});
// Tabs
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabPanels.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
});
});
// Two-way UI sync
document.querySelectorAll('.sidebar-content input, .sidebar-content select').forEach(el => {
el.addEventListener('input', updateConfigFromUI);
el.addEventListener('change', updateConfigFromUI);
});
// Preset Manager
btnSavePreset.addEventListener('click', () => {
const name = prompt('Preset Name eingeben:', 'Mein Custom Style');
if (!name) return;
const key = 'custom_' + Date.now();
const presetObj = Object.assign({}, state.config, { name });
const saved = JSON.parse(localStorage.getItem('viz_user_presets') || '{}');
saved[key] = presetObj;
localStorage.setItem('viz_user_presets', JSON.stringify(saved));
alert(`Preset "${name}" erfolgreich gespeichert!`);
});
btnExportPresetJson.addEventListener('click', () => {
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(state.config, null, 2));
const a = document.createElement('a');
a.href = dataStr;
a.download = `viz_preset_${(state.config.name || 'custom').toLowerCase().replace(/\s+/g, '_')}.json`;
a.click();
});
if (btnImportPresetJson) {
btnImportPresetJson.addEventListener('click', () => {
importPresetInput.value = '';
importPresetInput.click();
});
}
importPresetInput.addEventListener('change', (e) => {
if (e.target.files.length) {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const imported = JSON.parse(evt.target.result);
applyPreset(imported);
alert('Preset erfolgreich geladen!');
} catch (err) {
alert('Ungültiges Preset JSON');
}
};
reader.readAsText(e.target.files[0]);
}
});
const exportFormatSelect = document.getElementById('exportFormat');
if (exportFormatSelect) {
exportFormatSelect.addEventListener('change', updateExportEngineBadge);
}
btnStartExport.addEventListener('click', startExport);
headerExportBtn.addEventListener('click', () => {
document.querySelector('[data-tab="tab-export"]').click();
});
btnCancelExport.addEventListener('click', cancelExport);
const btnRefreshHistory = document.getElementById('btnRefreshHistory');
if (btnRefreshHistory) {
btnRefreshHistory.addEventListener('click', fetchRenderHistory);
}
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
e.preventDefault();
btnPlayPause.click();
}
});
}
async function fetchRenderHistory() {
try {
const res = await fetch('/api/export/history');
const history = await res.json();
const listContainer = document.getElementById('exportHistoryList');
if (!listContainer) return;
if (!history || history.length === 0) {
listContainer.innerHTML = '<div class="empty-state">Noch keine Renderings im Verlauf.</div>';
return;
}
listContainer.innerHTML = '';
history.forEach(sess => {
const item = document.createElement('div');
item.className = 'export-history-item';
let statusHtml = sess.error
? `<span style="color: #ff3333">❌ Fehler</span>`
: (sess.done ? `<span style="color: #00ff88">✅ Fertig</span>` : `<span style="color: #ffaa00">🔄 Rendert...</span>`);
const d = new Date(sess.createdAt);
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
let downloadsHtml = '';
if (sess.done && !sess.error && sess.formats) {
downloadsHtml = `
<div class="export-history-actions">
<a href="/api/export/download/${sess.id}/mp4" class="btn btn-primary" download>⬇️ MP4</a>
<a href="/api/export/download/${sess.id}/webm_alpha" class="btn btn-secondary" download>⬇️ WebM (Alpha)</a>
<a href="/api/export/download/${sess.id}/prores" class="btn btn-secondary" download>⬇️ ProRes</a>
<a href="/api/export/download/${sess.id}/qtrle" class="btn btn-secondary" download>⬇️ QTRLE</a>
</div>
`;
} else if (!sess.done && !sess.error) {
downloadsHtml = `<div class="export-history-meta">Dieses Rendering läuft gerade...</div>`;
}
item.innerHTML = `
<div class="export-history-header">
<div class="export-history-title">${sess.title}</div>
<div class="export-history-meta">${statusHtml}</div>
</div>
<div class="export-history-meta">${dateStr} | ID: ${sess.id.substring(0, 15)}...</div>
${downloadsHtml}
`;
listContainer.appendChild(item);
});
} catch (err) {
console.error('Failed to fetch render history:', err);
}
}
async function checkActiveSessions() {
try {
const res = await fetch('/api/export/history');
const history = await res.json();
const runningSession = history.find(s => !s.done && !s.error);
if (runningSession) {
currentExportSessionId = runningSession.id;
state.isExporting = true;
btnStartExport.classList.add('hidden');
exportProgressCard.classList.remove('hidden');
exportStatusText.textContent = 'Stelle Verbindung zum Server-Render wieder her...';
exportPercent.textContent = '...';
monitorServerSession(runningSession.id, 'all').catch(err => {
console.error(err);
alert('Fehler beim Überwachen des wiederhergestellten Render-Jobs: ' + err.message);
}).finally(() => {
state.isExporting = false;
currentExportSessionId = null;
exportProgressCard.classList.add('hidden');
btnStartExport.classList.remove('hidden');
});
}
} catch (e) {
console.warn('Could not check active sessions on load', e);
}
}
// --- Initialize App ---
function init() {
setAspectRatio('16:9');
applyPreset('trap_nation');
setupEvents();
updateExportEngineBadge();
fetchRenderHistory();
checkActiveSessions();
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
requestAnimationFrame(mainLoop);
}
init();
})();