1114 lines
41 KiB
JavaScript
1114 lines
41 KiB
JavaScript
/**
|
|
* VIZ Audio Visualizer Studio — Core Application & Rendering Engine
|
|
*/
|
|
|
|
(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,
|
|
isBeatDrop: false,
|
|
|
|
// Particles Pool
|
|
particles: [],
|
|
|
|
// Export Controller
|
|
isExporting: false,
|
|
exportAbortController: null,
|
|
|
|
// Active Settings
|
|
config: Object.assign({}, window.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 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 importPresetInput = document.getElementById('importPresetInput');
|
|
|
|
// Generate Default Stylish Synthwave Cover Placeholder
|
|
function generateDefaultCover() {
|
|
const off = document.createElement('canvas');
|
|
off.width = 600;
|
|
off.height = 600;
|
|
const c = off.getContext('2d');
|
|
|
|
// Gradient Background
|
|
const grad = c.createLinearGradient(0, 0, 600, 600);
|
|
grad.addColorStop(0, '#101728');
|
|
grad.addColorStop(0.5, '#1e1136');
|
|
grad.addColorStop(1, '#0b0c16');
|
|
c.fillStyle = grad;
|
|
c.fillRect(0, 0, 600, 600);
|
|
|
|
// Glowing Sun
|
|
const sunGrad = c.createRadialGradient(300, 240, 10, 300, 240, 180);
|
|
sunGrad.addColorStop(0, '#ff007f');
|
|
sunGrad.addColorStop(0.6, '#ffaa00');
|
|
sunGrad.addColorStop(1, 'transparent');
|
|
c.fillStyle = sunGrad;
|
|
c.beginPath();
|
|
c.arc(300, 240, 180, 0, Math.PI * 2);
|
|
c.fill();
|
|
|
|
// Geometric Grid Lines
|
|
c.strokeStyle = 'rgba(0, 240, 255, 0.4)';
|
|
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();
|
|
}
|
|
|
|
// Modern V Icon / Logo in center
|
|
c.fillStyle = '#ffffff';
|
|
c.shadowColor = '#00f0ff';
|
|
c.shadowBlur = 20;
|
|
c.font = '900 120px 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();
|
|
|
|
// --- Initialize Audio Context ---
|
|
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();
|
|
}
|
|
}
|
|
|
|
// --- Audio Loading ---
|
|
async function loadAudioFile(file) {
|
|
initAudioContext();
|
|
state.audioFile = file;
|
|
audioFileBadge.textContent = file.name;
|
|
audioFileBadge.classList.remove('hidden');
|
|
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
state.audioBuffer = await state.audioCtx.decodeAudioData(arrayBuffer);
|
|
state.duration = state.audioBuffer.duration;
|
|
state.audioLoaded = true;
|
|
|
|
// Update track title default if empty
|
|
const rawName = file.name.replace(/\.[^/.]+$/, '');
|
|
const titleInput = document.getElementById('trackTitle');
|
|
if (titleInput && (titleInput.value === 'Dein Songtitel' || !titleInput.value)) {
|
|
titleInput.value = rawName;
|
|
}
|
|
|
|
durationTimeEl.textContent = formatTime(state.duration);
|
|
seekScrubber.max = state.duration;
|
|
seekScrubber.value = 0;
|
|
state.pauseOffset = 0;
|
|
|
|
stopPlayback();
|
|
startPlayback(0);
|
|
}
|
|
|
|
// --- Image Loading ---
|
|
function loadImageFile(file) {
|
|
state.imageFile = file;
|
|
imageFileBadge.textContent = file.name;
|
|
imageFileBadge.classList.remove('hidden');
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
state.coverImage = img;
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// --- Playback Controls ---
|
|
function startPlayback(offset = 0) {
|
|
if (!state.audioBuffer) return;
|
|
initAudioContext();
|
|
|
|
if (state.audioSource) {
|
|
try {
|
|
state.audioSource.stop();
|
|
state.audioSource.disconnect();
|
|
} catch (e) {}
|
|
}
|
|
|
|
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);
|
|
|
|
state.startTime = state.audioCtx.currentTime - offset;
|
|
state.audioSource.start(0, offset);
|
|
state.isPlaying = true;
|
|
|
|
iconPlay.classList.add('hidden');
|
|
iconPause.classList.remove('hidden');
|
|
}
|
|
|
|
function pausePlayback() {
|
|
if (!state.isPlaying) return;
|
|
if (state.audioSource) {
|
|
state.pauseOffset = (state.audioCtx.currentTime - state.startTime) % state.duration;
|
|
try {
|
|
state.audioSource.stop();
|
|
state.audioSource.disconnect();
|
|
} catch (e) {}
|
|
}
|
|
state.isPlaying = false;
|
|
iconPlay.classList.remove('hidden');
|
|
iconPause.classList.add('hidden');
|
|
}
|
|
|
|
function stopPlayback() {
|
|
if (state.audioSource) {
|
|
try {
|
|
state.audioSource.stop();
|
|
state.audioSource.disconnect();
|
|
} catch (e) {}
|
|
}
|
|
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) {
|
|
let t = (state.audioCtx.currentTime - state.startTime);
|
|
if (state.loop) {
|
|
return t % state.duration;
|
|
}
|
|
return Math.min(t, state.duration);
|
|
}
|
|
return state.pauseOffset;
|
|
}
|
|
|
|
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.5 + 1.0,
|
|
alpha: Math.random() * 0.7 + 0.3,
|
|
speedMultiplier: Math.random() * 0.8 + 0.6,
|
|
colorOffset: Math.random()
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Dynamic Color Gradient Builder ---
|
|
function createVisualizerGradient(ctx, style, c1, c2, radius, angle, time) {
|
|
if (style === 'angular') {
|
|
const grad = ctx.createConicGradient(angle, 0, 0);
|
|
grad.addColorStop(0, c1);
|
|
grad.addColorStop(0.5, c2);
|
|
grad.addColorStop(1, c1);
|
|
return grad;
|
|
} else if (style === 'radial') {
|
|
const grad = ctx.createRadialGradient(0, 0, radius * 0.8, 0, 0, radius * 2.2);
|
|
grad.addColorStop(0, c1);
|
|
grad.addColorStop(1, c2);
|
|
return grad;
|
|
} else if (style === 'rainbow') {
|
|
const grad = ctx.createConicGradient(time * 0.5, 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;
|
|
} else {
|
|
const grad = ctx.createLinearGradient(-radius * 1.5, 0, radius * 1.5, 0);
|
|
grad.addColorStop(0, c1);
|
|
grad.addColorStop(1, c2);
|
|
return grad;
|
|
}
|
|
}
|
|
|
|
// --- Main Render Frame Function (Pure & Frame-Accuracy Support) ---
|
|
function renderFrame(targetCtx, width, height, freqData, currentTime, duration, cfg) {
|
|
targetCtx.save();
|
|
targetCtx.clearRect(0, 0, width, height);
|
|
|
|
// 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 = Math.max(subBassAvg * 1.2, midBassAvg);
|
|
state.beatEnergy = rawBassEnergy;
|
|
|
|
// Target Pulse calculation with smooth attack/decay
|
|
const targetPulse = 1.0 + (rawBassEnergy * cfg.pulseIntensity * 0.6);
|
|
const decay = cfg.pulseDecay || 0.8;
|
|
if (targetPulse > state.currentPulse) {
|
|
state.currentPulse = targetPulse; // Fast attack
|
|
} else {
|
|
state.currentPulse = state.currentPulse * decay + targetPulse * (1 - decay); // Smooth decay
|
|
}
|
|
|
|
// Camera Shake & Zoom calculation
|
|
let shakeZoom = 1.0;
|
|
let shakeX = 0;
|
|
let shakeY = 0;
|
|
|
|
if (cfg.camShakeIntensity > 0 && rawBassEnergy > 0.4) {
|
|
const shakeFactor = (rawBassEnergy - 0.4) * cfg.camShakeIntensity * 35;
|
|
shakeX = (Math.random() - 0.5) * shakeFactor;
|
|
shakeY = (Math.random() - 0.5) * shakeFactor;
|
|
shakeZoom = 1.0 + (rawBassEnergy - 0.4) * cfg.camShakeIntensity * 0.08;
|
|
}
|
|
|
|
// Rotation calculation
|
|
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.04;
|
|
}
|
|
|
|
// --- Layer 1: Background ---
|
|
if (cfg.backgroundMode === 'blurred_cover' && state.coverImage) {
|
|
targetCtx.save();
|
|
targetCtx.filter = 'blur(40px) brightness(0.4)';
|
|
const scale = Math.max(width / state.coverImage.width, height / state.coverImage.height) * 1.2 * 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.7);
|
|
vig.addColorStop(0, '#121622');
|
|
vig.addColorStop(1, '#05070a');
|
|
targetCtx.fillStyle = vig;
|
|
targetCtx.fillRect(0, 0, width, height);
|
|
} else if (cfg.backgroundMode === 'solid_black') {
|
|
targetCtx.fillStyle = '#000000';
|
|
targetCtx.fillRect(0, 0, width, height);
|
|
} // If 'transparent', clearRect leaves it 100% alpha!
|
|
|
|
// --- Layer 2: Beat Flash FX ---
|
|
if (cfg.beatFlash && rawBassEnergy > 0.85) {
|
|
targetCtx.fillStyle = `rgba(255, 255, 255, ${(rawBassEnergy - 0.85) * 0.4})`;
|
|
targetCtx.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
// Transform Matrix for Centered World Coordinates & Camera Shake
|
|
targetCtx.save();
|
|
targetCtx.translate(width / 2 + shakeX, height / 2 + shakeY);
|
|
targetCtx.scale(shakeZoom, shakeZoom);
|
|
|
|
// --- Layer 3: Particle System ---
|
|
if (cfg.enableParticles && state.particles.length > 0) {
|
|
targetCtx.save();
|
|
const speedMult = cfg.particleSpeed * (1.0 + (rawBassEnergy * cfg.particleWarp));
|
|
|
|
for (let i = 0; i < state.particles.length; i++) {
|
|
const p = state.particles[i];
|
|
p.z -= speedMult * p.speedMultiplier * 5;
|
|
|
|
// Warp loop
|
|
if (p.z <= 10) {
|
|
p.z = 1000;
|
|
p.x = (Math.random() - 0.5) * width;
|
|
p.y = (Math.random() - 0.5) * height;
|
|
}
|
|
|
|
const k = 400 / p.z;
|
|
const px = p.x * k;
|
|
const py = p.y * k;
|
|
const pSize = Math.max(0.5, p.radius * k);
|
|
|
|
if (Math.abs(px) < width / 2 && Math.abs(py) < height / 2) {
|
|
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
|
|
targetCtx.globalAlpha = Math.min(1.0, p.alpha * (1 - p.z / 1000));
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(px, py, pSize, 0, Math.PI * 2);
|
|
targetCtx.fill();
|
|
}
|
|
}
|
|
targetCtx.restore();
|
|
}
|
|
|
|
// --- Layer 4: Spectrum Rendering ---
|
|
const baseRadius = cfg.circleRadius * state.currentPulse;
|
|
const barCount = cfg.barCount || 96;
|
|
const maxHeight = cfg.spectrumMaxHeight || 140;
|
|
const barSpacing = cfg.barSpacing || 10;
|
|
const sensitivity = cfg.freqSensitivity || 1.3;
|
|
|
|
// Build Gradient
|
|
const spectrumGrad = createVisualizerGradient(
|
|
targetCtx,
|
|
cfg.gradientStyle,
|
|
cfg.colorGrad1,
|
|
cfg.colorGrad2,
|
|
baseRadius,
|
|
state.currentRotation,
|
|
currentTime
|
|
);
|
|
|
|
targetCtx.save();
|
|
targetCtx.fillStyle = spectrumGrad;
|
|
targetCtx.strokeStyle = spectrumGrad;
|
|
|
|
// Glow Effect
|
|
if (cfg.glowIntensity > 0) {
|
|
targetCtx.shadowColor = cfg.colorGrad1;
|
|
targetCtx.shadowBlur = cfg.glowBlur * (0.8 + rawBassEnergy * cfg.glowIntensity);
|
|
}
|
|
|
|
// Filtered frequency array based on focus
|
|
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));
|
|
}
|
|
|
|
// Draw Spectrum by Mode
|
|
if (cfg.spectrumMode === 'radial_bars') {
|
|
const angleStep = (Math.PI * 2) / barCount;
|
|
const barW = cfg.barWidth || 4;
|
|
|
|
for (let i = 0; i < barCount; i++) {
|
|
const angle = i * angleStep;
|
|
const barH = processedBars[i];
|
|
const rStart = baseRadius + barSpacing;
|
|
|
|
targetCtx.save();
|
|
targetCtx.rotate(angle);
|
|
targetCtx.fillRect(-barW / 2, -rStart - barH, barW, barH);
|
|
targetCtx.restore();
|
|
}
|
|
} else if (cfg.spectrumMode === 'smooth_wave') {
|
|
const angleStep = (Math.PI * 2) / barCount;
|
|
targetCtx.beginPath();
|
|
for (let i = 0; i <= barCount; i++) {
|
|
const idx = i % barCount;
|
|
const angle = i * angleStep;
|
|
const r = baseRadius + barSpacing + processedBars[idx];
|
|
const x = Math.cos(angle - Math.PI / 2) * r;
|
|
const y = Math.sin(angle - Math.PI / 2) * r;
|
|
|
|
if (i === 0) targetCtx.moveTo(x, y);
|
|
else targetCtx.lineTo(x, y);
|
|
}
|
|
targetCtx.closePath();
|
|
targetCtx.lineWidth = cfg.barWidth * 1.5;
|
|
targetCtx.stroke();
|
|
} else if (cfg.spectrumMode === 'floating_dots') {
|
|
const angleStep = (Math.PI * 2) / barCount;
|
|
for (let i = 0; i < barCount; i++) {
|
|
const angle = i * angleStep;
|
|
const r = baseRadius + barSpacing + processedBars[i];
|
|
const x = Math.cos(angle - Math.PI / 2) * r;
|
|
const y = Math.sin(angle - Math.PI / 2) * r;
|
|
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(x, y, cfg.barWidth * 1.2, 0, Math.PI * 2);
|
|
targetCtx.fill();
|
|
}
|
|
} else if (cfg.spectrumMode === 'dual_ring') {
|
|
const angleStep = (Math.PI * 2) / barCount;
|
|
const barW = cfg.barWidth || 3;
|
|
|
|
// Inner Ring (Bass)
|
|
for (let i = 0; i < barCount; i++) {
|
|
const angle = i * angleStep;
|
|
const barH = processedBars[i] * 0.7;
|
|
const rStart = baseRadius + barSpacing;
|
|
|
|
targetCtx.save();
|
|
targetCtx.rotate(angle);
|
|
targetCtx.fillRect(-barW / 2, -rStart - barH, barW, barH);
|
|
targetCtx.restore();
|
|
}
|
|
|
|
// Outer Ring (Treble Dots)
|
|
targetCtx.fillStyle = cfg.colorGrad2;
|
|
for (let i = 0; i < barCount; i += 2) {
|
|
const angle = i * angleStep + (currentTime * 0.2);
|
|
const r = baseRadius + barSpacing + maxHeight * 0.8 + (processedBars[i] * 0.4);
|
|
const x = Math.cos(angle) * r;
|
|
const y = Math.sin(angle) * r;
|
|
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(x, y, barW, 0, Math.PI * 2);
|
|
targetCtx.fill();
|
|
}
|
|
}
|
|
targetCtx.restore();
|
|
|
|
// --- Layer 5: Central Circle Artwork ---
|
|
targetCtx.save();
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(0, 0, baseRadius, 0, Math.PI * 2);
|
|
targetCtx.clip();
|
|
|
|
if (state.coverImage) {
|
|
targetCtx.save();
|
|
targetCtx.rotate(state.currentRotation);
|
|
targetCtx.drawImage(
|
|
state.coverImage,
|
|
-baseRadius,
|
|
-baseRadius,
|
|
baseRadius * 2,
|
|
baseRadius * 2
|
|
);
|
|
targetCtx.restore();
|
|
}
|
|
targetCtx.restore();
|
|
|
|
// --- Layer 6: Circle Border & Progress Ring ---
|
|
if (cfg.borderWidth > 0) {
|
|
targetCtx.save();
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(0, 0, baseRadius, 0, Math.PI * 2);
|
|
targetCtx.lineWidth = cfg.borderWidth;
|
|
targetCtx.strokeStyle = cfg.borderColor || '#ffffff';
|
|
targetCtx.stroke();
|
|
targetCtx.restore();
|
|
}
|
|
|
|
if (cfg.showProgressRing && duration > 0) {
|
|
const progressAngle = (currentTime / duration) * (Math.PI * 2);
|
|
targetCtx.save();
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(0, 0, baseRadius + cfg.borderWidth / 2 + 1, -Math.PI / 2, -Math.PI / 2 + progressAngle);
|
|
targetCtx.lineWidth = Math.max(3, cfg.borderWidth * 0.8);
|
|
targetCtx.strokeStyle = cfg.progressRingColor || '#00f0ff';
|
|
targetCtx.lineCap = 'round';
|
|
targetCtx.stroke();
|
|
targetCtx.restore();
|
|
}
|
|
|
|
targetCtx.restore(); // Restore Centered Coordinates
|
|
|
|
// --- Layer 7: Typography & Song Info ---
|
|
if (cfg.showTextOverlay) {
|
|
const title = document.getElementById('trackTitle').value || 'Song Title';
|
|
const artist = document.getElementById('trackArtist').value || 'Artist';
|
|
|
|
targetCtx.save();
|
|
targetCtx.textAlign = 'center';
|
|
targetCtx.textBaseline = 'middle';
|
|
|
|
let textY = height / 2 + baseRadius + maxHeight + 70;
|
|
if (cfg.textPosition === 'bottom_center') {
|
|
textY = height - 100;
|
|
} else if (cfg.textPosition === 'top_center') {
|
|
textY = 100;
|
|
}
|
|
|
|
// Audio-reactive text scale
|
|
const textPulse = cfg.textPulseWithBeat ? (1.0 + rawBassEnergy * 0.05) : 1.0;
|
|
targetCtx.translate(width / 2, textY);
|
|
targetCtx.scale(textPulse, textPulse);
|
|
|
|
// Title
|
|
targetCtx.font = `700 ${cfg.titleFontSize}px ${cfg.fontFamily}`;
|
|
targetCtx.fillStyle = '#ffffff';
|
|
targetCtx.shadowColor = cfg.colorGrad1;
|
|
targetCtx.shadowBlur = 12;
|
|
targetCtx.fillText(title, 0, 0);
|
|
|
|
// Artist
|
|
targetCtx.font = `500 ${cfg.artistFontSize}px ${cfg.fontFamily}`;
|
|
targetCtx.fillStyle = 'rgba(255, 255, 255, 0.75)';
|
|
targetCtx.shadowBlur = 0;
|
|
targetCtx.fillText(artist, 0, cfg.titleFontSize * 0.8 + 8);
|
|
|
|
// Timecode
|
|
if (cfg.showTimecode && duration > 0) {
|
|
targetCtx.font = `600 16px 'Outfit', sans-serif`;
|
|
targetCtx.fillStyle = cfg.colorGrad1;
|
|
targetCtx.fillText(`${formatTime(currentTime)} / ${formatTime(duration)}`, 0, cfg.titleFontSize * 0.8 + cfg.artistFontSize + 18);
|
|
}
|
|
|
|
targetCtx.restore();
|
|
}
|
|
|
|
targetCtx.restore(); // Restore Root Context
|
|
}
|
|
|
|
// --- Real-Time Animation Loop (60 FPS) ---
|
|
const freqArray = new Uint8Array(1024);
|
|
|
|
function mainLoop() {
|
|
if (!state.isExporting) {
|
|
if (state.analyser && state.isPlaying) {
|
|
state.analyser.getByteFrequencyData(freqArray);
|
|
} else if (!state.isPlaying) {
|
|
// Subtle idle pulse
|
|
const t = performance.now() * 0.003;
|
|
for (let i = 0; i < freqArray.length; i++) {
|
|
freqArray[i] = Math.max(0, Math.sin(t + i * 0.1) * 35);
|
|
}
|
|
}
|
|
|
|
const curTime = getCurrentPlaybackTime();
|
|
if (state.isPlaying) {
|
|
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);
|
|
}
|
|
|
|
requestAnimationFrame(mainLoop);
|
|
}
|
|
|
|
// --- UI Synchronizer ---
|
|
function updateConfigFromUI() {
|
|
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.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.rotationSpeed = parseFloat(document.getElementById('rotationSpeed').value);
|
|
state.config.beatSpinImpulse = document.getElementById('beatSpinImpulse').checked;
|
|
|
|
const checkedMode = document.querySelector('input[name="spectrumMode"]:checked');
|
|
if (checkedMode) state.config.spectrumMode = checkedMode.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.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.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.camShakeIntensity = parseFloat(document.getElementById('camShakeIntensity').value);
|
|
state.config.rgbSplitIntensity = parseFloat(document.getElementById('rgbSplitIntensity').value);
|
|
state.config.beatFlash = document.getElementById('beatFlash').checked;
|
|
|
|
state.config.colorGrad1 = document.getElementById('colorGrad1').value;
|
|
state.config.colorGrad2 = document.getElementById('colorGrad2').value;
|
|
state.config.colorParticle = document.getElementById('colorParticle').value;
|
|
state.config.gradientStyle = document.getElementById('gradientStyle').value;
|
|
state.config.glowIntensity = parseFloat(document.getElementById('glowIntensity').value);
|
|
state.config.glowBlur = parseInt(document.getElementById('glowBlur').value, 10);
|
|
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.titleFontSize = parseInt(document.getElementById('titleFontSize').value, 10);
|
|
state.config.artistFontSize = parseInt(document.getElementById('artistFontSize').value, 10);
|
|
state.config.textPulseWithBeat = document.getElementById('textPulseWithBeat').checked;
|
|
state.config.showTimecode = document.getElementById('showTimecode').checked;
|
|
|
|
// 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('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('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('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('valTitleFontSize').textContent = `${state.config.titleFontSize}px`;
|
|
document.getElementById('valArtistFontSize').textContent = `${state.config.artistFontSize}px`;
|
|
|
|
initParticles(state.config.particleCount);
|
|
}
|
|
|
|
function applyPreset(presetKeyOrObj) {
|
|
let p = typeof presetKeyOrObj === 'string' ? window.VIZ_PRESETS[presetKeyOrObj] : presetKeyOrObj;
|
|
if (!p) return;
|
|
|
|
Object.assign(state.config, p);
|
|
|
|
// Sync form elements with preset values
|
|
document.getElementById('circleRadius').value = state.config.circleRadius;
|
|
document.getElementById('pulseIntensity').value = state.config.pulseIntensity;
|
|
document.getElementById('pulseDecay').value = state.config.pulseDecay;
|
|
document.getElementById('borderWidth').value = state.config.borderWidth;
|
|
document.getElementById('borderColor').value = state.config.borderColor;
|
|
document.getElementById('showProgressRing').checked = state.config.showProgressRing;
|
|
document.getElementById('progressRingColor').value = state.config.progressRingColor;
|
|
document.getElementById('rotationSpeed').value = state.config.rotationSpeed;
|
|
document.getElementById('beatSpinImpulse').checked = state.config.beatSpinImpulse;
|
|
|
|
const modeRadio = document.querySelector(`input[name="spectrumMode"][value="${state.config.spectrumMode}"]`);
|
|
if (modeRadio) modeRadio.checked = true;
|
|
|
|
document.getElementById('barCount').value = state.config.barCount;
|
|
document.getElementById('spectrumMaxHeight').value = state.config.spectrumMaxHeight;
|
|
document.getElementById('barWidth').value = state.config.barWidth;
|
|
document.getElementById('barSpacing').value = state.config.barSpacing;
|
|
document.getElementById('freqSensitivity').value = state.config.freqSensitivity;
|
|
document.getElementById('freqFocus').value = state.config.freqFocus;
|
|
document.getElementById('mirrorSpectrum').checked = state.config.mirrorSpectrum;
|
|
|
|
document.getElementById('enableParticles').checked = state.config.enableParticles;
|
|
document.getElementById('particleCount').value = state.config.particleCount;
|
|
document.getElementById('particleSpeed').value = state.config.particleSpeed;
|
|
document.getElementById('particleWarp').value = state.config.particleWarp;
|
|
|
|
document.getElementById('camShakeIntensity').value = state.config.camShakeIntensity;
|
|
document.getElementById('rgbSplitIntensity').value = state.config.rgbSplitIntensity;
|
|
document.getElementById('beatFlash').checked = state.config.beatFlash;
|
|
|
|
document.getElementById('colorGrad1').value = state.config.colorGrad1;
|
|
document.getElementById('colorGrad2').value = state.config.colorGrad2;
|
|
document.getElementById('colorParticle').value = state.config.colorParticle;
|
|
document.getElementById('gradientStyle').value = state.config.gradientStyle;
|
|
document.getElementById('glowIntensity').value = state.config.glowIntensity;
|
|
document.getElementById('glowBlur').value = state.config.glowBlur;
|
|
document.getElementById('backgroundMode').value = state.config.backgroundMode;
|
|
|
|
document.getElementById('fontFamily').value = state.config.fontFamily;
|
|
document.getElementById('textPosition').value = state.config.textPosition;
|
|
document.getElementById('titleFontSize').value = state.config.titleFontSize;
|
|
document.getElementById('artistFontSize').value = state.config.artistFontSize;
|
|
document.getElementById('textPulseWithBeat').checked = state.config.textPulseWithBeat;
|
|
document.getElementById('showTimecode').checked = state.config.showTimecode;
|
|
|
|
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('has-grid');
|
|
|
|
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);
|
|
}
|
|
|
|
// --- Export Pipeline (ProRes 4444 MOV with Alpha / WebM Alpha / MP4) ---
|
|
async function startExport() {
|
|
if (!state.audioLoaded || !state.audioFile) {
|
|
alert('Bitte lade zuerst eine Audio-Datei (.wav oder .mp3) hoch!');
|
|
return;
|
|
}
|
|
|
|
const format = document.getElementById('exportFormat').value; // prores, webm, mp4
|
|
const res = document.getElementById('exportResolution').value; // 1080p, 4k, 720p
|
|
const fps = parseInt(document.getElementById('exportFps').value, 10);
|
|
|
|
let renderWidth = state.width;
|
|
let renderHeight = state.height;
|
|
|
|
if (res === '4k') {
|
|
renderWidth = state.aspectRatio === '16:9' ? 3840 : (state.aspectRatio === '9:16' ? 2160 : 2160);
|
|
renderHeight = state.aspectRatio === '16:9' ? 2160 : (state.aspectRatio === '9:16' ? 3840 : 2160);
|
|
} else if (res === '720p') {
|
|
renderWidth = state.aspectRatio === '16:9' ? 1280 : (state.aspectRatio === '9:16' ? 720 : 720);
|
|
renderHeight = state.aspectRatio === '16:9' ? 720 : (state.aspectRatio === '9:16' ? 1280 : 720);
|
|
}
|
|
|
|
state.isExporting = true;
|
|
exportProgressCard.classList.remove('hidden');
|
|
btnStartExport.classList.add('hidden');
|
|
exportStatusText.textContent = 'Bereite Video-Render vor...';
|
|
exportPercent.textContent = '0%';
|
|
exportProgressBarFill.style.width = '0%';
|
|
|
|
pausePlayback();
|
|
|
|
// Prepare FormData for server-side FFmpeg processing
|
|
const formData = new FormData();
|
|
formData.append('audio', state.audioFile);
|
|
if (state.imageFile) {
|
|
formData.append('cover', state.imageFile);
|
|
}
|
|
formData.append('config', JSON.stringify(state.config));
|
|
formData.append('format', format);
|
|
formData.append('width', renderWidth);
|
|
formData.append('height', renderHeight);
|
|
formData.append('fps', fps);
|
|
formData.append('aspectRatio', state.aspectRatio);
|
|
formData.append('title', document.getElementById('trackTitle').value);
|
|
formData.append('artist', document.getElementById('trackArtist').value);
|
|
|
|
state.exportAbortController = new AbortController();
|
|
|
|
try {
|
|
exportStatusText.textContent = 'Rendert Video (FFmpeg ProRes/Alpha)...';
|
|
|
|
const response = await fetch('/api/render', {
|
|
method: 'POST',
|
|
body: formData,
|
|
signal: state.exportAbortController.signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
throw new Error(errText || 'Render-Fehler auf dem Server');
|
|
}
|
|
|
|
// Track download progress if streamed or grab blob
|
|
const blob = await response.blob();
|
|
const filename = `viz_${(document.getElementById('trackTitle').value || 'video').replace(/[^a-z0-9]/gi, '_')}_${format === 'prores' ? 'ProRes4444_Alpha.mov' : (format === 'webm' ? 'Alpha.webm' : 'video.mp4')}`;
|
|
|
|
// Trigger instant browser download
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(downloadUrl);
|
|
|
|
exportStatusText.textContent = '✅ Fertig! Download abgeschlossen.';
|
|
exportPercent.textContent = '100%';
|
|
exportProgressBarFill.style.width = '100%';
|
|
setTimeout(() => {
|
|
exportProgressCard.classList.add('hidden');
|
|
btnStartExport.classList.remove('hidden');
|
|
state.isExporting = false;
|
|
}, 3000);
|
|
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
exportStatusText.textContent = 'Render abgebrochen.';
|
|
} else {
|
|
alert('Render-Fehler: ' + err.message);
|
|
exportStatusText.textContent = 'Fehler beim Rendern';
|
|
}
|
|
exportProgressCard.classList.add('hidden');
|
|
btnStartExport.classList.remove('hidden');
|
|
state.isExporting = false;
|
|
}
|
|
}
|
|
|
|
function cancelExport() {
|
|
if (state.exportAbortController) {
|
|
state.exportAbortController.abort();
|
|
}
|
|
state.isExporting = false;
|
|
exportProgressCard.classList.add('hidden');
|
|
btnStartExport.classList.remove('hidden');
|
|
}
|
|
|
|
// --- Event Listeners & Binding ---
|
|
function setupEvents() {
|
|
// Dropzones & File Inputs
|
|
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]);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Player controls
|
|
btnPlayPause.addEventListener('click', () => {
|
|
if (state.isPlaying) pausePlayback();
|
|
else startPlayback(state.pauseOffset);
|
|
});
|
|
|
|
btnRewind.addEventListener('click', () => {
|
|
const cur = getCurrentPlaybackTime();
|
|
startPlayback(Math.max(0, cur - 5));
|
|
});
|
|
|
|
btnForward.addEventListener('click', () => {
|
|
const cur = getCurrentPlaybackTime();
|
|
startPlayback(Math.min(state.duration, cur + 5));
|
|
});
|
|
|
|
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);
|
|
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;
|
|
});
|
|
|
|
// Aspect Switchers
|
|
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('has-grid', e.target.checked);
|
|
});
|
|
|
|
quickPresetSelect.addEventListener('change', (e) => {
|
|
applyPreset(e.target.value);
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
// Dynamic UI Inputs (Two-way binding)
|
|
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();
|
|
});
|
|
|
|
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]);
|
|
}
|
|
});
|
|
|
|
// Export Buttons
|
|
btnStartExport.addEventListener('click', startExport);
|
|
headerExportBtn.addEventListener('click', () => {
|
|
// Switch to export tab and start
|
|
document.querySelector('[data-tab="tab-export"]').click();
|
|
});
|
|
btnCancelExport.addEventListener('click', cancelExport);
|
|
|
|
// Keyboard Shortcuts (Spacebar Play/Pause)
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
btnPlayPause.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Initialize App ---
|
|
function init() {
|
|
setAspectRatio('16:9');
|
|
applyPreset('trap_nation');
|
|
setupEvents();
|
|
requestAnimationFrame(mainLoop);
|
|
}
|
|
|
|
init();
|
|
})();
|