2985 lines
118 KiB
JavaScript
2985 lines
118 KiB
JavaScript
/**
|
|
* 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' });
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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 Level 5.2, 5.1 for 4K/TopBottom, then 4.2)
|
|
configsToTest.push(
|
|
{ codec: 'avc1.640034', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
|
|
{ codec: 'avc1.640034', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
|
|
{ codec: 'avc1.640033', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-hardware' },
|
|
{ codec: 'avc1.640033', width, height, bitrate, framerate: fps, hardwareAcceleration: 'prefer-software' },
|
|
{ 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();
|
|
}
|
|
|
|
let getTopBottomCanvasOffscreen = null;
|
|
let getTopBottomCanvasTb = null;
|
|
|
|
function getTopBottomCanvas(sourceCanvas) {
|
|
const width = sourceCanvas.width;
|
|
const height = sourceCanvas.height;
|
|
|
|
if (!getTopBottomCanvasOffscreen || getTopBottomCanvasOffscreen.width !== width || getTopBottomCanvasOffscreen.height !== height) {
|
|
getTopBottomCanvasOffscreen = new OffscreenCanvas(width, height);
|
|
getTopBottomCanvasTb = new OffscreenCanvas(width, height * 2);
|
|
}
|
|
|
|
const aCtx = getTopBottomCanvasOffscreen.getContext('2d', { willReadFrequently: true });
|
|
|
|
// 1. Create Grayscale Alpha mask
|
|
aCtx.globalCompositeOperation = 'source-over';
|
|
aCtx.fillStyle = 'white';
|
|
aCtx.fillRect(0, 0, width, height);
|
|
|
|
aCtx.globalCompositeOperation = 'destination-in';
|
|
aCtx.drawImage(sourceCanvas, 0, 0);
|
|
|
|
aCtx.globalCompositeOperation = 'destination-over';
|
|
aCtx.fillStyle = 'black';
|
|
aCtx.fillRect(0, 0, width, height);
|
|
|
|
// 2. Assemble Top-Bottom Canvas
|
|
const tbCtx = getTopBottomCanvasTb.getContext('2d', { alpha: false, willReadFrequently: true });
|
|
|
|
// Top: RGB over black
|
|
tbCtx.fillStyle = 'black';
|
|
tbCtx.fillRect(0, 0, width, height);
|
|
tbCtx.drawImage(sourceCanvas, 0, 0);
|
|
|
|
// Bottom: Alpha Mask
|
|
tbCtx.drawImage(getTopBottomCanvasOffscreen, 0, height);
|
|
|
|
return getTopBottomCanvasTb;
|
|
}
|
|
|
|
function preMultiplyAlpha(ctx, width, height) {
|
|
const imgData = ctx.getImageData(0, 0, width, height);
|
|
const d = imgData.data;
|
|
for (let i = 0; i < d.length; i += 4) {
|
|
const a = d[i + 3];
|
|
if (a === 255) continue;
|
|
if (a === 0) { d[i] = d[i+1] = d[i+2] = 0; continue; }
|
|
d[i] = (d[i] * a + 127) >> 8;
|
|
d[i + 1] = (d[i + 1] * a + 127) >> 8;
|
|
d[i + 2] = (d[i + 2] * a + 127) >> 8;
|
|
}
|
|
ctx.putImageData(imgData, 0, 0);
|
|
}
|
|
|
|
/**
|
|
* 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 isTopBottom = (format === 'mp4_topbottom');
|
|
const ext = isWebM ? 'webm' : 'mp4';
|
|
const suffix = isWebM ? 'Alpha_DaVinci' : (isTopBottom ? 'Mezzanine_TB' : 'Visualizer');
|
|
const outputFilename = `${title}_${suffix}.${ext}`;
|
|
const encoderHeight = isTopBottom ? targetHeight * 2 : targetHeight;
|
|
|
|
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, encoderHeight, 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: encoderHeight
|
|
},
|
|
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);
|
|
|
|
let finalFrameCanvas = renderCanvas;
|
|
if (isTopBottom) {
|
|
finalFrameCanvas = getTopBottomCanvas(renderCanvas);
|
|
}
|
|
|
|
if (supportsAlpha && !isTopBottom) {
|
|
preMultiplyAlpha(
|
|
finalFrameCanvas.getContext('2d'),
|
|
finalFrameCanvas.width,
|
|
finalFrameCanvas.height
|
|
);
|
|
}
|
|
|
|
// 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(finalFrameCanvas, {
|
|
timestamp: timestampUs,
|
|
duration: durationUs,
|
|
alpha: (supportsAlpha && !isTopBottom) ? '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');
|
|
const isTransparent = (state.config.backgroundMode === 'transparent');
|
|
const isDirectWebm = (format === 'webm_alpha');
|
|
const useTopBottom = isTransparent && (format === 'prores' || format === 'webm_alpha');
|
|
|
|
if (hasWebCodecs) {
|
|
// Step 1: Render intermediate mezzanine video at 60-120+ FPS locally on GPU
|
|
let mezzanineFormat = 'mp4';
|
|
if (format === 'prores' || format === 'webm_alpha') {
|
|
mezzanineFormat = useTopBottom ? 'mp4_topbottom' : (isDirectWebm ? '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('isTopBottom', useTopBottom ? 'true' : 'false');
|
|
formData.append('width', targetWidth);
|
|
formData.append('height', targetHeight);
|
|
formData.append('fps', fps);
|
|
formData.append('title', title);
|
|
|
|
const exportSettings = {
|
|
config: state.config,
|
|
aspectRatio: state.aspectRatio,
|
|
width: state.width,
|
|
height: state.height,
|
|
fps: fps,
|
|
title: title
|
|
};
|
|
formData.append('settings', JSON.stringify(exportSettings));
|
|
|
|
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 {
|
|
exportStatusText.textContent = `Server verarbeitet Render-Job: ${statusJson.currentStatus}`;
|
|
if (exportFpsLive) exportFpsLive.textContent = '';
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const isTransparent = (state.config.backgroundMode === 'transparent');
|
|
|
|
if (!isTransparent && (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]);
|
|
}
|
|
});
|
|
|
|
// Export format listener removed as it is now static
|
|
|
|
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);
|
|
}
|
|
|
|
const exportHistoryList = document.getElementById('exportHistoryList');
|
|
if (exportHistoryList) {
|
|
exportHistoryList.addEventListener('click', async (e) => {
|
|
const btnDelete = e.target.closest('.btn-delete-render');
|
|
if (btnDelete) {
|
|
const id = btnDelete.dataset.id;
|
|
if (confirm('Bist du sicher, dass du diesen Render (inkl. Videodateien) unwiderruflich löschen möchtest?')) {
|
|
try {
|
|
const res = await fetch(`/api/export/history/${id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
fetchRenderHistory();
|
|
} else {
|
|
alert(data.error || 'Fehler beim Löschen.');
|
|
}
|
|
} catch (err) {
|
|
alert('Fehler: ' + err.message);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const btnRerender = e.target.closest('.btn-rerender');
|
|
if (btnRerender) {
|
|
try {
|
|
const settingsStr = btnRerender.dataset.settings;
|
|
if (!settingsStr) throw new Error('Keine Einstellungen gefunden.');
|
|
const settings = JSON.parse(settingsStr);
|
|
|
|
// Apply config to UI
|
|
if (settings.config) {
|
|
applyPreset(settings.config);
|
|
}
|
|
|
|
// Restore other settings
|
|
if (settings.aspectRatio) {
|
|
const arSelect = document.getElementById('exportAspectRatio');
|
|
if (arSelect) {
|
|
arSelect.value = settings.aspectRatio;
|
|
// Dispatch change event to update width/height
|
|
arSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
}
|
|
|
|
if (settings.fps) {
|
|
const fpsInput = document.getElementById('exportFps');
|
|
if (fpsInput) fpsInput.value = settings.fps;
|
|
}
|
|
|
|
if (settings.title) {
|
|
const titleInput = document.getElementById('exportTitle');
|
|
if (titleInput) titleInput.value = settings.title;
|
|
}
|
|
|
|
// Switch to editor view
|
|
document.querySelector('[data-tab="tab-editor"]').click();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
alert('Die Einstellungen für dieses Rendering wurden erfolgreich geladen!');
|
|
|
|
} catch (err) {
|
|
console.error('Fehler beim Laden der Einstellungen:', err);
|
|
alert('Fehler beim Laden der Einstellungen.');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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.formats) {
|
|
downloadsHtml = '<div class="export-history-actions">';
|
|
if (sess.formats.mp4 && sess.formats.mp4.ready) {
|
|
downloadsHtml += `<a href="/api/export/download/${sess.id}/mp4" class="btn btn-primary" download>🎬 MP4</a> `;
|
|
}
|
|
if (sess.formats.webm_alpha && sess.formats.webm_alpha.ready) {
|
|
downloadsHtml += `<a href="/api/export/download/${sess.id}/webm_alpha" class="btn btn-secondary" download>👻 WebM (Alpha)</a> `;
|
|
}
|
|
if (sess.formats.prores && sess.formats.prores.ready) {
|
|
downloadsHtml += `<a href="/api/export/download/${sess.id}/prores" class="btn btn-secondary" download>🍎 ProRes</a> `;
|
|
}
|
|
if (sess.formats.qtrle && sess.formats.qtrle.ready) {
|
|
downloadsHtml += `<a href="/api/export/download/${sess.id}/qtrle" class="btn btn-secondary" download>🎞️ QTRLE</a>`;
|
|
}
|
|
downloadsHtml += '</div>';
|
|
}
|
|
if (!sess.done && !sess.error) {
|
|
downloadsHtml += `<div class="export-history-meta" style="margin-top: 8px;">Dieses Rendering läuft gerade... Weitere Formate folgen.</div>`;
|
|
}
|
|
|
|
let adminActionsHtml = `
|
|
<div class="export-history-actions" style="margin-top: 10px; border-top: 1px solid #333; padding-top: 10px;">
|
|
<button class="btn btn-danger btn-delete-render" data-id="${sess.id}">🗑️ Löschen</button>
|
|
<button class="btn btn-primary btn-rerender" data-settings='${JSON.stringify(sess.settings || {}).replace(/'/g, "'")}'>🔄 Re-Render</button>
|
|
</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}
|
|
${adminActionsHtml}
|
|
`;
|
|
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();
|
|
})();
|