From a287dd539b960e67a7157f5037ea863e5c6eef5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 24 Jun 2026 19:57:28 +0300 Subject: [PATCH] =?UTF-8?q?=C3=96zel=20=C5=9Fablonlar,=20sprites=20katman?= =?UTF-8?q?=C4=B1=20ve=20admin=20paneli=20iyile=C5=9Ftirmeleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/admin/admin.css | 36 ++++ src/admin/admin.js | 284 ++++++++++++++++++++++++++++++++ src/exporter/exporter.js | 46 +++++- src/exporter/index.html | 1 + src/main/main.js | 57 ++++++- src/main/preload-admin.js | 4 + src/shared/defaults.js | 48 ++++++ src/visualizer/index.html | 3 + src/visualizer/modes/sprites.js | 190 +++++++++++++++++++++ src/visualizer/visualizer.css | 8 +- src/visualizer/visualizer.js | 40 +++++ 11 files changed, 702 insertions(+), 15 deletions(-) create mode 100644 src/visualizer/modes/sprites.js diff --git a/src/admin/admin.css b/src/admin/admin.css index d871e56..d79737b 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -182,6 +182,42 @@ input[type=color]::-webkit-color-swatch { border: none; border-radius: 6px; } .subctrls { padding-left: 2px; border-left: 2px solid var(--line); padding-left: 12px; margin-top: 6px; } .hidden { display: none !important; } +/* ---------- Kullanıcı renk şablonları ---------- */ +.user-presets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.up-empty { font-size: 11.5px; color: var(--muted); line-height: 1.5; padding: 6px 2px; } +.up-item { + display: flex; align-items: stretch; gap: 10px; padding: 7px; + border-radius: 10px; background: var(--card2); border: 1px solid var(--line); +} +.up-swatch { width: 44px; border-radius: 7px; cursor: pointer; border: 1px solid var(--line); flex-shrink: 0; } +.up-swatch:hover { border-color: var(--accent); } +.up-main { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.up-name { + background: var(--card); color: var(--text); border: 1px solid var(--line); + border-radius: 7px; padding: 5px 8px; font-size: 12.5px; outline: none; width: 100%; +} +.up-name:focus { border-color: var(--accent); } +.up-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.up-actions .btn { padding: 5px 9px; font-size: 11px; } +.up-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } +.btn.danger { color: #ff8a8a; border-color: rgba(255,80,80,.35); } +.btn.danger:hover { background: rgba(255,60,60,.12); } + +/* ---------- Ek görsel nesneler ---------- */ +.img-card { + border: 1px solid var(--line); border-radius: 12px; background: var(--card2); + padding: 10px; margin-top: 10px; +} +.img-head { display: flex; gap: 10px; align-items: center; } +.img-thumb { + width: 52px; height: 52px; object-fit: contain; border-radius: 9px; + background: #0c0a0d url('data:image/svg+xml;utf8,'); + border: 1px solid var(--line); flex-shrink: 0; +} +.img-headmain { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.img-body { margin-top: 10px; padding-top: 8px; border-top: 1px dashed var(--line); } +.img-body .ctrl { margin-bottom: 10px; } + /* ---------- Video dışa aktarma ---------- */ .export-filename { margin-top: 8px; font-size: 12px; color: var(--muted); diff --git a/src/admin/admin.js b/src/admin/admin.js index e2c3293..9b17b3e 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -89,6 +89,12 @@ return colorsCtrl(def); case 'presets': return presetsCtrl(def); + case 'userpresets': + return userPresetsCtrl(def); + case 'bgio': + return bgIoCtrl(def); + case 'images': + return imagesCtrl(def); case 'logofile': return logoFileCtrl(def); case 'xy': @@ -266,6 +272,129 @@ ]); } + // --- Kullanıcı renk şablonları (kaydet/yeniden adlandır/güncelle/sil + içe/dışa) --- + function userPresetsCtrl() { + if (!Array.isArray(cfg.userPresets)) cfg.userPresets = []; + const wrap = el('div', { class: 'ctrl' }); + wrap.appendChild(el('label', { class: 'lbl', text: 'Kendi Şablonlarım' })); + + const list = el('div', { class: 'user-presets' }); + if (!cfg.userPresets.length) { + list.appendChild(el('div', { class: 'up-empty', text: 'Henüz şablon yok. Aşağıdaki renkleri ayarlayıp “Mevcut Renkleri Kaydet”e basın.' })); + } + cfg.userPresets.forEach((p) => { + const swatch = el('div', { class: 'up-swatch' }); + swatch.style.background = `linear-gradient(90deg, ${(p.colors || []).join(',')})`; + swatch.title = 'Uygula'; + swatch.addEventListener('click', () => actions.applyUserPreset(p.id)); + + const nameInput = el('input', { + type: 'text', class: 'up-name', value: p.name || 'Şablon', + onchange: (e) => { p.name = e.target.value.trim() || 'Şablon'; push(true); }, + }); + + const applyBtn = el('button', { class: 'btn ghost small', text: 'Uygula', onclick: () => actions.applyUserPreset(p.id) }); + const updateBtn = el('button', { class: 'btn ghost small', text: '⟳ Güncelle', title: 'Mevcut renklerle güncelle', onclick: () => actions.updateUserPreset(p.id) }); + const delBtn = el('button', { class: 'btn ghost small danger', text: '🗑', title: 'Sil', onclick: () => actions.deleteUserPreset(p.id) }); + + const row = el('div', { class: 'up-item' }, [ + swatch, + el('div', { class: 'up-main' }, [nameInput, el('div', { class: 'up-actions' }, [applyBtn, updateBtn, delBtn])]), + ]); + list.appendChild(row); + }); + wrap.appendChild(list); + + const saveBtn = el('button', { class: 'btn ghost small', text: '💾 Mevcut Renkleri Kaydet', onclick: () => actions.saveCurrentPreset() }); + const expBtn = el('button', { class: 'btn ghost small', text: '📤 Dışa Aktar', onclick: () => actions.exportPresets() }); + const impBtn = el('button', { class: 'btn ghost small', text: '📥 İçe Aktar', onclick: () => actions.importPresets() }); + const bar = el('div', { class: 'up-toolbar' }, [saveBtn, expBtn, impBtn]); + wrap.appendChild(bar); + return wrap; + } + + // --- Arkaplan ayarlarını içe/dışa aktarma --- + function bgIoCtrl() { + const expBtn = el('button', { class: 'btn ghost small', text: '📤 Arkaplanı Dışa Aktar', onclick: () => actions.exportBackground() }); + const impBtn = el('button', { class: 'btn ghost small', text: '📥 Arkaplanı İçe Aktar', onclick: () => actions.importBackground() }); + return el('div', { class: 'ctrl' }, [ + el('label', { class: 'lbl', text: 'Arkaplan Ayarları (dosya)' }), + el('div', { class: 'up-toolbar' }, [expBtn, impBtn]), + ]); + } + + // --- Ek görsel nesneler / partiküller yöneticisi --- + const MOTION_OPTS = [ + { value: 'static', label: 'Sabit' }, + { value: 'float', label: 'Süzülme' }, + { value: 'orbit', label: 'Yörünge' }, + { value: 'swirl', label: 'Girdap' }, + { value: 'scatter', label: 'Saçılma (sese)' }, + { value: 'rise', label: 'Yükselme' }, + { value: 'fall', label: 'Düşme' }, + ]; + const BLEND_OPTS = [ + { value: 'normal', label: 'Normal' }, + { value: 'screen', label: 'Ekran (parlak)' }, + { value: 'add', label: 'Toplama (ışıltı)' }, + ]; + const LAYER_OPTS = [ + { value: 'front', label: 'Önde' }, + { value: 'back', label: 'Arkada' }, + ]; + + function imagesCtrl() { + if (!cfg.images) cfg.images = { enabled: false, items: [] }; + if (!Array.isArray(cfg.images.items)) cfg.images.items = []; + const wrap = el('div', {}); + + const items = cfg.images.items; + if (!items.length) { + wrap.appendChild(el('div', { class: 'up-empty', text: 'Görsel eklemek için aşağıdaki düğmeyi kullanın. Her görsel için çok sayıda kopya (partikül) sahnede gezinir/saçılır.' })); + } + + items.forEach((it, idx) => { + const base = 'images.items.' + idx + '.'; + const thumb = el('img', { class: 'img-thumb' }); + if (it.src) thumb.src = it.src; + + const nameInput = el('input', { + type: 'text', class: 'up-name', value: it.name || 'Görsel', + onchange: (e) => { it.name = e.target.value.trim() || 'Görsel'; push(true); }, + }); + const delBtn = el('button', { class: 'btn ghost small danger', text: '🗑 Kaldır', onclick: () => actions.removeImage(it.id) }); + const replaceBtn = el('button', { class: 'btn ghost small', text: '🖼 Değiştir', onclick: () => actions.replaceImage(it.id) }); + + const head = el('div', { class: 'img-head' }, [ + thumb, + el('div', { class: 'img-headmain' }, [nameInput, el('div', { class: 'up-actions' }, [replaceBtn, delBtn])]), + ]); + + const body = el('div', { class: 'img-body' }); + const add = (c) => { const e = buildControl(c); if (e) body.appendChild(e); }; + add({ type: 'select', path: base + 'motion', label: 'Hareket', options: MOTION_OPTS, rebuild: false }); + add({ type: 'slider', path: base + 'count', label: 'Kopya Sayısı', min: 1, max: 200, step: 1 }); + add({ type: 'slider', path: base + 'size', label: 'Boyut', min: 0.01, max: 0.4, step: 0.005, percent: true }); + add({ type: 'slider', path: base + 'sizeVar', label: 'Boyut Çeşitliliği', min: 0, max: 0.95, step: 0.05, percent: true }); + add({ type: 'slider', path: base + 'opacity', label: 'Saydamlık', min: 0, max: 1, step: 0.02, percent: true }); + add({ type: 'slider', path: base + 'spread', label: 'Yayılma / Alan', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'speed', label: 'Hız', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'spin', label: 'Dönüş', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'audioSize', label: 'Ses → Boyut', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'audioSpeed', label: 'Ses → Hız', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'audioOpacity', label: 'Ses → Saydamlık', min: 0, max: 2, step: 0.05 }); + add({ type: 'slider', path: base + 'glow', label: 'Parlama', min: 0, max: 1, step: 0.02, percent: true }); + add({ type: 'select', path: base + 'blend', label: 'Karışım', options: BLEND_OPTS }); + add({ type: 'select', path: base + 'layer', label: 'Katman', options: LAYER_OPTS }); + + wrap.appendChild(el('div', { class: 'img-card' }, [head, body])); + }); + + const addBtn = el('button', { class: 'btn ghost small', text: '➕ Görsel Ekle', onclick: () => actions.addImage() }); + wrap.appendChild(el('div', { class: 'up-toolbar' }, [addBtn])); + return el('div', { class: 'ctrl' }, [wrap]); + } + function logoFileCtrl() { const fileInput = el('input', { type: 'file', accept: 'image/*' }); fileInput.style.display = 'none'; @@ -465,6 +594,8 @@ }, { type: 'colors', path: 'background.gradient.colors', label: 'Renkler (5 nokta)', show: () => cfg.background.type === 'gradient' }, { type: 'presets', show: () => cfg.background.type === 'gradient' }, + { type: 'userpresets', show: () => cfg.background.type === 'gradient' }, + { type: 'bgio' }, { type: 'slider', path: 'background.gradient.speed', label: 'Akış Hızı', min: 0, max: 2, step: 0.02, show: () => cfg.background.type === 'gradient' }, { type: 'slider', path: 'background.gradient.drift', label: 'Tek Yönlü Kayma', min: 0, max: 1, step: 0.01, percent: true, show: () => cfg.background.type === 'gradient' }, { type: 'slider', path: 'background.gradient.wander', label: 'Gezinme Alanı', min: 0, max: 2, step: 0.02, show: () => cfg.background.type === 'gradient' }, @@ -529,6 +660,15 @@ { type: 'xy', show: () => cfg.logo.enabled }, ], }, + { + icon: '✨', + title: 'Görsel Nesneler / Partiküller', + desc: 'Bir veya birden fazla resim ekle; sahnede süzülsün, yörünge çizsin, sese göre saçılsın. Boyut, hız, saydamlık, ışıltı ve katman ayarlanır.', + controls: [ + { type: 'toggle', path: 'images.enabled', label: 'Görsel Nesneleri Etkinleştir', rebuild: true }, + { type: 'images', show: () => cfg.images && cfg.images.enabled }, + ], + }, { icon: '⚡', title: 'Güç / Performans', @@ -588,6 +728,14 @@ { value: 'balanced', label: 'Dengeli (daha küçük dosya)' }, ], }, + { + type: 'select', path: 'export.speed', label: 'Hız / Kalite Dengesi', + options: [ + { value: 'fast', label: '⚡ Hızlı (en hızlı dışa aktarım)' }, + { value: 'balanced', label: 'Dengeli (önerilen)' }, + { value: 'quality', label: 'Kalite (en yavaş, en iyi sıkıştırma)' }, + ], + }, { type: 'exportpanel' }, ], }, @@ -664,6 +812,135 @@ render(); }; + // -------------------------------------------------------------------------- + // Kullanıcı renk şablonları + // -------------------------------------------------------------------------- + function uid(prefix) { + return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + } + function ensurePresets() { + if (!Array.isArray(cfg.userPresets)) cfg.userPresets = []; + return cfg.userPresets; + } + + actions.saveCurrentPreset = () => { + const arr = ensurePresets(); + const colors = (cfg.background.gradient.colors || []).slice(0, 5); + while (colors.length < 5) colors.push(colors[colors.length - 1] || '#000000'); + arr.push({ id: uid('up_'), name: 'Şablonum ' + (arr.length + 1), colors }); + push(true); + render(); + }; + actions.applyUserPreset = (id) => { + const p = ensurePresets().find((x) => x.id === id); + if (!p) return; + cfg.background.gradient.colors = (p.colors || []).slice(); + push(true); + render(); + }; + actions.updateUserPreset = (id) => { + const p = ensurePresets().find((x) => x.id === id); + if (!p) return; + p.colors = (cfg.background.gradient.colors || []).slice(0, 5); + push(true); + render(); + }; + actions.deleteUserPreset = (id) => { + if (!confirm('Bu şablon silinsin mi?')) return; + cfg.userPresets = ensurePresets().filter((x) => x.id !== id); + push(true); + render(); + }; + actions.exportPresets = async () => { + const arr = ensurePresets(); + if (!arr.length) { alert('Dışa aktarılacak şablon yok.'); return; } + await window.api.exportJson('renk-sablonlari.json', { type: 'sv-presets', version: 1, presets: arr }); + }; + actions.importPresets = async () => { + const r = await window.api.importJson('Renk Şablonlarını İçe Aktar'); + if (!r || !r.ok) { if (r && r.error) alert('İçe aktarılamadı: ' + r.error); return; } + const incoming = Array.isArray(r.data) ? r.data : (r.data && r.data.presets) || []; + if (!Array.isArray(incoming) || !incoming.length) { alert('Dosyada şablon bulunamadı.'); return; } + const arr = ensurePresets(); + incoming.forEach((p) => { + let colors = Array.isArray(p.colors) ? p.colors.slice(0, 5) : []; + if (!colors.length) return; + while (colors.length < 5) colors.push(colors[colors.length - 1]); + arr.push({ id: uid('up_'), name: (p.name || 'İçe Aktarılan').toString(), colors }); + }); + push(true); + render(); + }; + + // -------------------------------------------------------------------------- + // Arkaplan ayarları içe/dışa aktarma + // -------------------------------------------------------------------------- + actions.exportBackground = async () => { + await window.api.exportJson('arkaplan-ayarlari.json', { type: 'sv-background', version: 1, background: cfg.background }); + }; + actions.importBackground = async () => { + const r = await window.api.importJson('Arkaplan Ayarlarını İçe Aktar'); + if (!r || !r.ok) { if (r && r.error) alert('İçe aktarılamadı: ' + r.error); return; } + const bg = r.data && (r.data.background || (r.data.type && r.data.gradient ? r.data : null)); + if (!bg) { alert('Geçerli bir arkaplan dosyası değil.'); return; } + const def = window.SV.defaultConfig().background; + cfg.background = window.SV.deepMerge(def, bg); + push(true); + render(); + }; + + // -------------------------------------------------------------------------- + // Ek görsel nesneler + // -------------------------------------------------------------------------- + function pickImageFile(cb) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.display = 'none'; + input.addEventListener('change', () => { + const file = input.files && input.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => cb(reader.result); + reader.readAsDataURL(file); + }); + document.body.appendChild(input); + input.click(); + setTimeout(() => input.remove(), 1000); + } + function ensureImages() { + if (!cfg.images) cfg.images = { enabled: false, items: [] }; + if (!Array.isArray(cfg.images.items)) cfg.images.items = []; + return cfg.images; + } + + actions.addImage = () => { + pickImageFile((dataUrl) => { + const imgs = ensureImages(); + const item = window.SV.imageItem({ src: dataUrl, name: 'Görsel ' + (imgs.items.length + 1) }); + imgs.items.push(item); + imgs.enabled = true; + push(true); + render(); + }); + }; + actions.replaceImage = (id) => { + pickImageFile((dataUrl) => { + const imgs = ensureImages(); + const it = imgs.items.find((x) => x.id === id); + if (!it) return; + it.src = dataUrl; + push(true); + render(); + }); + }; + actions.removeImage = (id) => { + const imgs = ensureImages(); + imgs.items = imgs.items.filter((x) => x.id !== id); + push(true); + render(); + }; + // -------------------------------------------------------------------------- // Video dışa aktarma // -------------------------------------------------------------------------- @@ -706,6 +983,7 @@ fps: cfg.export.fps, quality: cfg.export.quality, encoder: cfg.export.encoder, + speed: cfg.export.speed, }); if (!r || !r.ok) { setExportStatus('⚠ ' + ((r && r.error) || 'Başlatılamadı'), 'err'); @@ -720,6 +998,12 @@ const saved = await window.api.getSettings(); if (saved) cfg = window.SV.deepMerge(window.SV.defaultConfig(), saved); + // Eski/eksik görsel nesneleri varsayılan alanlarla tamamla + if (cfg.images && Array.isArray(cfg.images.items)) { + cfg.images.items = cfg.images.items.map((it) => window.SV.normalizeImageItem(it)); + } + if (!Array.isArray(cfg.userPresets)) cfg.userPresets = []; + displays = await window.api.getDisplays(); audioDevices = (await window.api.getOutputDevices()) || []; diff --git a/src/exporter/exporter.js b/src/exporter/exporter.js index 6c0bd0c..b8d9513 100644 --- a/src/exporter/exporter.js +++ b/src/exporter/exporter.js @@ -63,6 +63,7 @@ let cfg = null; let gradient = null; let foreground = null; + let sprites = null; let logo = null; let pcm = null; @@ -164,6 +165,13 @@ if (foreground.resize) foreground.resize(); } + // Ek görsel nesneler / partiküller (resimler kare 0'dan önce yüklenir) + if (cfg.images && cfg.images.enabled && Array.isArray(cfg.images.items) && cfg.images.items.length) { + sprites = new window.SVSprites(); + sprites.setItems(cfg.images.items); + await sprites.whenReady(); + } + audio.applyConfig(cfg.audio); if (cfg.logo.enabled && cfg.logo.src) { @@ -211,12 +219,18 @@ compCtx.fillRect(0, 0, width, height); } + // Ek görsel nesneler — arka katman (görselin arkasında) + if (sprites && sprites.hasLayer('back')) sprites.draw(compCtx, audio, t, width, height, 'back'); + // Ön görselleştirici (kendi kanvasını temizleyip çizer) if (foreground) { foreground.draw(audio, cfg, t, dt); compCtx.drawImage(c2d, 0, 0, width, height); } + // Ek görsel nesneler — ön katman (görselin önünde) + if (sprites && sprites.hasLayer('front')) sprites.draw(compCtx, audio, t, width, height, 'front'); + // Logo drawLogo(); @@ -229,17 +243,35 @@ totalFrames = Math.max(1, Math.ceil(duration * fps)); window.exp.ready(totalFrames); + // Boru hattı (pipeline): render, IPC ve ffmpeg kodlaması ÜST ÜSTE çalışsın. + // Karelerin tek tek beklenmesi yerine WINDOW kadar kareyi "uçuşta" tutarız; + // böylece render ederken ffmpeg de önceki kareleri kodlar. Bu, GPU'da + // dışa aktarımı belirgin biçimde hızlandırır (darboğaz = max(render, encode)). + // WINDOW, bellek için kare boyutuna göre sınırlanır (~64 MB tampon). + const frameBytes = width * height * 4; + const WINDOW = Math.max(3, Math.min(12, Math.round((64 * 1024 * 1024) / frameBytes))); + const progStep = Math.max(1, Math.round(fps / 4)); - for (let i = 0; i < totalFrames; i++) { + let cancelled = false; + const pending = []; + + for (let i = 0; i < totalFrames && !cancelled; i++) { const data = renderFrame(i); - // Geri basınç: ana süreç kareyi ffmpeg.stdin'e yazana kadar bekle. - const res = await window.exp.sendFrame(data, i); - if (res && res.cancel) { - window.exp.cancelled(); - return; - } + // Geri basınç: ana süreç kareyi ffmpeg.stdin'e yazana kadar bekler; ama + // hemen await etmeyip pencereyi dolduruyoruz (render/encode örtüşmesi). + const p = window.exp.sendFrame(data, i).then((res) => { + if (res && res.cancel) cancelled = true; + }); + pending.push(p); + if (pending.length >= WINDOW) await pending.shift(); if (i % progStep === 0) window.exp.progress(i + 1, totalFrames); } + + await Promise.all(pending); + if (cancelled) { + window.exp.cancelled(); + return; + } window.exp.progress(totalFrames, totalFrames); window.exp.finish(); } diff --git a/src/exporter/index.html b/src/exporter/index.html index 18f94e4..7cf5d54 100644 --- a/src/exporter/index.html +++ b/src/exporter/index.html @@ -25,6 +25,7 @@ + diff --git a/src/main/main.js b/src/main/main.js index bd6cf4f..17f9192 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -266,6 +266,39 @@ ipcMain.on('update-config', (e, config) => { // Görselleştirici açıldığında mevcut yapılandırmayı ister ipcMain.handle('request-config', () => currentConfig); +// ---------------------------------------------------------------------------- +// Genel JSON içe/dışa aktarma (renk şablonları + arkaplan ayarları) +// ---------------------------------------------------------------------------- +ipcMain.handle('file:export-json', async (e, defaultName, data) => { + try { + const r = await dialog.showSaveDialog(adminWin, { + title: 'Dışa Aktar', + defaultPath: defaultName || 'disa-aktarim.json', + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (r.canceled || !r.filePath) return { ok: false, canceled: true }; + fs.writeFileSync(r.filePath, JSON.stringify(data, null, 2), 'utf-8'); + return { ok: true, path: r.filePath }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +ipcMain.handle('file:import-json', async (e, title) => { + try { + const r = await dialog.showOpenDialog(adminWin, { + title: title || 'İçe Aktar', + properties: ['openFile'], + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (r.canceled || !r.filePaths[0]) return { ok: false, canceled: true }; + const raw = fs.readFileSync(r.filePaths[0], 'utf-8'); + return { ok: true, data: JSON.parse(raw) }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + // Görselleştirici -> admin (ses seviyesi göstergesi vb.) ipcMain.on('audio-meter', (e, data) => { notifyAdmin('audio-meter', data); @@ -338,20 +371,32 @@ function detectGpuEncoder() { }); } +// NVENC preset/ince ayar tablosu (hız/kalite dengesi). p1=en hızlı … p7=en yavaş. +// 'fast' düşük gecikme (ll) + lookahead/B-kare yok -> maksimum hız. +const NVENC_SPEED = { + fast: { preset: 'p2', extra: ['-tune', 'ull', '-rc-lookahead', '0', '-bf', '0'] }, + balanced: { preset: 'p4', extra: ['-tune', 'hq', '-rc-lookahead', '8', '-bf', '2'] }, + quality: { preset: 'p6', extra: ['-tune', 'hq', '-rc-lookahead', '20', '-bf', '3', '-multipass', 'qres'] }, +}; +// libx264 preset'leri (hız/kalite dengesi). +const X264_SPEED = { fast: 'veryfast', balanced: 'medium', quality: 'slow' }; + // Kodlayıcıya göre video argümanları (GPU = NVENC, CPU = libx264). Her ikisi de // yuv420p H.264 üretir; görsel-kayıpsız kalite kademeleri eşlenmiştir. -function buildVideoArgs(encoder, quality) { +// quality = sıkıştırma kalitesi (CQ/CRF), speed = hız/kalite preset'i. +function buildVideoArgs(encoder, quality, speed) { if (encoder === 'gpu') { const cq = CQ_MAP[quality] != null ? CQ_MAP[quality] : 16; + const s = NVENC_SPEED[speed] || NVENC_SPEED.balanced; return [ - // p5 = kalite-dengeli NVENC preset'i; p7'den belirgin hızlı, kalite ~aynı. - '-c:v', 'h264_nvenc', '-preset', 'p5', '-tune', 'hq', + '-c:v', 'h264_nvenc', '-preset', s.preset, ...s.extra, '-rc', 'vbr', '-cq', String(cq), '-b:v', '0', '-profile:v', 'high', '-pix_fmt', 'yuv420p', ]; } const crf = CRF_MAP[quality] != null ? CRF_MAP[quality] : 14; - return ['-c:v', 'libx264', '-preset', 'medium', '-crf', String(crf), '-pix_fmt', 'yuv420p']; + const preset = X264_SPEED[speed] || 'medium'; + return ['-c:v', 'libx264', '-preset', preset, '-crf', String(crf), '-pix_fmt', 'yuv420p', '-threads', '0']; } ipcMain.handle('export:gpu-available', () => detectGpuEncoder()); @@ -393,7 +438,8 @@ ipcMain.handle('export:start', async (e, opts) => { // Kodlayıcı seçimi: GPU (NVENC) istendi ama yoksa sessizce CPU'ya düş. let encoder = opts.encoder === 'gpu' ? 'gpu' : 'cpu'; if (encoder === 'gpu' && !(await detectGpuEncoder())) encoder = 'cpu'; - const videoArgs = buildVideoArgs(encoder, opts.quality); + const speed = ['fast', 'balanced', 'quality'].includes(opts.speed) ? opts.speed : 'balanced'; + const videoArgs = buildVideoArgs(encoder, opts.quality, speed); // Ses akışını kayıpsız kopyala (mp4 uyumlu kodek). Değilse şeffaf AAC'e dön. const ext = path.extname(audioPath).toLowerCase(); @@ -406,6 +452,7 @@ ipcMain.handle('export:start', async (e, opts) => { '-pixel_format', 'rgba', '-video_size', `${w}x${h}`, '-framerate', String(fps), + '-thread_queue_size', '1024', // ham kare girişi için geniş kuyruk (boru hattı stall'ını önler) '-i', 'pipe:0', '-i', audioPath, '-map', '0:v:0', diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index 26e05a4..6b35b98 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -14,6 +14,10 @@ contextBridge.exposeInMainWorld('api', { closeVisualizer: () => ipcRenderer.invoke('close-visualizer'), updateConfig: (config) => ipcRenderer.send('update-config', config), + // JSON içe/dışa aktarma (renk şablonları + arkaplan ayarları) + exportJson: (name, data) => ipcRenderer.invoke('file:export-json', name, data), + importJson: (title) => ipcRenderer.invoke('file:import-json', title), + // Video dışa aktarma gpuAvailable: () => ipcRenderer.invoke('export:gpu-available'), pickExportAudio: () => ipcRenderer.invoke('export:pick-audio'), diff --git a/src/shared/defaults.js b/src/shared/defaults.js index b6704b8..d814476 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -72,6 +72,16 @@ hideCursor: true, }, + // Ek görsel nesneler / partiküller. Bir veya birden fazla resim yüklenip + // sahnede gezinir, saçılır, yörünge çizer; saydamlık + ses tepkisi ayarlanır. + images: { + enabled: false, + items: [], // imageItem() ile üretilen nesneler + }, + + // Kullanıcı renk şablonları (arkaplan gradyanı). Kalıcıdır; içe/dışa aktarılabilir. + userPresets: [], // { id, name, colors:[5] } + // Video dışa aktarma (MP3/ses -> kayıpsız video). Ekran/ses kaydı değil: // her kare offline ve birebir render edilir, ses kaynaktan kopyalanır. export: { @@ -79,9 +89,44 @@ fps: 60, // 30 | 60 quality: 'visually-lossless', // 'visually-lossless' | 'high' | 'balanced' encoder: 'gpu', // 'gpu' (NVIDIA NVENC, hızlı) | 'cpu' (libx264, en uyumlu) + speed: 'balanced', // 'fast' | 'balanced' | 'quality' — hız/kalite dengesi (preset) }, }; + // Ek görsel nesne (partikül emitör) varsayılanları. + const IMAGE_DEFAULTS = { + name: 'Görsel', + src: null, // dataURL + count: 14, // kopya / partikül sayısı + size: 0.07, // kısa kenara oran + sizeVar: 0.45, // boyut rastgeleliği (0..1) + opacity: 0.85, + motion: 'float', // 'static'|'float'|'orbit'|'swirl'|'scatter'|'rise'|'fall' + speed: 0.5, + spread: 0.7, // gezinme alanı / yörünge yarıçapı + spin: 0.15, // kendi ekseninde dönüş + audioSize: 0.45, // bas -> boyut nabzı + audioSpeed: 0.35, // seviye -> hareket hızı + audioOpacity: 0.0, // bas -> saydamlık nabzı + glow: 0.15, + blend: 'normal', // 'normal'|'screen'|'add' + layer: 'front', // 'front' (görselin önünde) | 'back' (arkasında) + }; + + // Yeni bir görsel nesne üret (benzersiz id + deterministik seed ile) + function imageItem(over) { + const seed = ((Date.now() & 0xffffff) ^ ((Math.random() * 0xffffff) | 0)) >>> 0 || 1; + return Object.assign({}, IMAGE_DEFAULTS, { id: 'img_' + seed.toString(36), seed }, over || {}); + } + + // Eksik alanları varsayılanlarla doldur (eski kayıtlar / içe aktarım için) + function normalizeImageItem(it) { + const out = Object.assign({}, IMAGE_DEFAULTS, it || {}); + if (out.seed == null) out.seed = ((Math.random() * 0xffffff) | 0) >>> 0 || 1; + if (out.id == null) out.id = 'img_' + out.seed.toString(36); + return out; + } + // Hazır renk şablonları (arkaplan gradyanı) const GRADIENT_PRESETS = [ { name: 'Aurora', colors: ['#5b4be0', '#3aa6ff', '#37e0c8', '#7be07b', '#d24bff'] }, @@ -139,6 +184,9 @@ window.SV = { DEFAULT_CONFIG, GRADIENT_PRESETS, + IMAGE_DEFAULTS, + imageItem, + normalizeImageItem, defaultConfig, deepMerge, clone, diff --git a/src/visualizer/index.html b/src/visualizer/index.html index 71b4b08..25ed056 100644 --- a/src/visualizer/index.html +++ b/src/visualizer/index.html @@ -8,7 +8,9 @@ + +
@@ -20,6 +22,7 @@ + diff --git a/src/visualizer/modes/sprites.js b/src/visualizer/modes/sprites.js new file mode 100644 index 0000000..d244c8c --- /dev/null +++ b/src/visualizer/modes/sprites.js @@ -0,0 +1,190 @@ +'use strict'; +/* Ek görsel nesneler / partikül katmanı (2D canvas). + Bir veya birden fazla resim yüklenir; her resim "emitör" olur ve N kopya + (partikül) üretir. Konumlar zamanın SAF (analitik) fonksiyonudur — dt ile + integral alınmaz — böylece canlı görselleştirici ile dışa aktarım birebir + aynı kareyi üretir (deterministik). Ses (bas/seviye) boyut, hız ve saçılmayı + anlık olarak modüle eder. */ +(function () { + // mulberry32 — küçük, hızlı, deterministik PRNG (partikül sabitleri için) + function mulberry32(a) { + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + function frac(x) { + return x - Math.floor(x); + } + function clamp(v, a, b) { + return v < a ? a : v > b ? b : v; + } + + class SpriteSystem { + constructor() { + this.items = []; + this.cache = new Map(); // src -> { img, ready, promise } + } + + _img(src) { + if (!src) return null; + let e = this.cache.get(src); + if (!e) { + const img = new Image(); + e = { img, ready: false, promise: null }; + e.promise = new Promise((res) => { + img.onload = () => { + e.ready = true; + res(); + }; + img.onerror = () => { + e.ready = false; + res(); + }; + }); + img.src = src; + this.cache.set(src, e); + } + return e; + } + + // Yapılandırmadan emitör listesini hazırla (her src için resmi yükler ve + // count adet partikülün deterministik sabitlerini üretir). + setItems(itemsCfg) { + const list = Array.isArray(itemsCfg) ? itemsCfg : []; + this.items = list.filter((c) => c && c.src).map((c) => this._prepare(c)); + } + + _prepare(cfg) { + const count = clamp(cfg.count | 0 || 1, 1, 240); + const rng = mulberry32((cfg.seed || 1) >>> 0); + const bx = new Float32Array(count); + const by = new Float32Array(count); + const ph = new Float32Array(count); + const fx = new Float32Array(count); + const fy = new Float32Array(count); + const sz = new Float32Array(count); + const ang = new Float32Array(count); + const rad = new Float32Array(count); + const spin = new Float32Array(count); + const al = new Float32Array(count); + for (let i = 0; i < count; i++) { + bx[i] = rng(); + by[i] = rng(); + ph[i] = rng() * Math.PI * 2; + fx[i] = 0.3 + rng() * 1.2; + fy[i] = 0.3 + rng() * 1.2; + sz[i] = 1 - clamp(cfg.sizeVar || 0, 0, 0.95) * rng(); + ang[i] = rng() * Math.PI * 2; + rad[i] = 0.15 + rng() * 0.85; + spin[i] = (rng() < 0.5 ? -1 : 1) * (0.5 + rng()); + al[i] = 0.55 + rng() * 0.45; + } + return { cfg, imgEntry: this._img(cfg.src), count, bx, by, ph, fx, fy, sz, ang, rad, spin, al }; + } + + // Tüm resimler yüklenene kadar bekle (dışa aktarımda kare 0'dan itibaren hazır olsun) + whenReady() { + return Promise.all(this.items.map((it) => (it.imgEntry ? it.imgEntry.promise : Promise.resolve()))); + } + + // Verilen katmana ('front'|'back') ait emitörleri ctx üzerine çiz. + draw(ctx, audio, t, W, H, layer) { + const minDim = Math.min(W, H); + const asp = W / H; + const bass = audio.bass || 0; + const level = audio.level || 0; + for (const it of this.items) { + const c = it.cfg; + if ((c.layer || 'front') !== layer) continue; + const e = it.imgEntry; + if (!e || !e.ready) continue; + const img = e.img; + const iw = img.naturalWidth || 1; + const ih = img.naturalHeight || 1; + const ratio = ih / iw; + const spread = c.spread != null ? c.spread : 0.7; + const speed = (c.speed || 0) * (1 + level * (c.audioSpeed || 0) * 2); + + ctx.save(); + ctx.globalCompositeOperation = + c.blend === 'screen' ? 'screen' : c.blend === 'add' ? 'lighter' : 'source-over'; + if ((c.glow || 0) > 0) { + ctx.shadowBlur = c.glow * 42 * (minDim / 1080); + ctx.shadowColor = 'rgba(255,255,255,0.55)'; + } + + for (let i = 0; i < it.count; i++) { + let px, py; + switch (c.motion) { + case 'static': + px = it.bx[i] + Math.sin(t * 0.6 + it.ph[i]) * 0.0025; + py = it.by[i] + Math.cos(t * 0.6 + it.ph[i]) * 0.0025; + break; + case 'orbit': { + const a = it.ang[i] + t * speed * it.spin[i] * 0.5; + const r = (0.12 + it.rad[i] * 0.4) * spread; + px = 0.5 + (Math.cos(a) * r) / asp; + py = 0.5 + Math.sin(a) * r; + break; + } + case 'swirl': { + const a = it.ang[i] + t * speed * it.spin[i] * 0.5; + const r = + (0.12 + it.rad[i] * 0.4) * spread * (0.7 + 0.3 * Math.sin(t * 0.8 + it.ph[i]) + bass * 0.4); + px = 0.5 + (Math.cos(a) * r) / asp; + py = 0.5 + Math.sin(a) * r; + break; + } + case 'scatter': { + const base = 0.04 + it.rad[i] * 0.05; + const burst = + base + (it.rad[i] * 0.6 + 0.2) * spread * (bass * 2.2 * (0.5 + (c.audioSpeed || 0)) + level * 0.3); + px = 0.5 + (Math.cos(it.ang[i]) * burst) / asp; + py = 0.5 + Math.sin(it.ang[i]) * burst; + break; + } + case 'rise': + px = it.bx[i] + Math.sin(t * speed * it.fx[i] + it.ph[i]) * 0.04 * spread; + py = frac(it.by[i] - t * speed * 0.08); + break; + case 'fall': + px = it.bx[i] + Math.sin(t * speed * it.fx[i] + it.ph[i]) * 0.04 * spread; + py = frac(it.by[i] + t * speed * 0.08); + break; + case 'float': + default: { + const amp = 0.18 * spread; + px = it.bx[i] + Math.sin(t * speed * it.fx[i] + it.ph[i]) * amp; + py = it.by[i] + Math.cos(t * speed * it.fy[i] + it.ph[i] * 1.3) * amp; + break; + } + } + + const s = (c.size || 0.06) * minDim * it.sz[i] * (1 + bass * (c.audioSize || 0)); + if (s < 0.5) continue; + const w = s; + const h = s * ratio; + let a = (c.opacity != null ? c.opacity : 1) * it.al[i] * (1 + bass * (c.audioOpacity || 0)); + ctx.globalAlpha = clamp(a, 0, 1); + const rot = it.ph[i] + t * (c.spin || 0) * it.spin[i] * 1.2; + ctx.translate(px * W, py * H); + ctx.rotate(rot); + ctx.drawImage(img, -w / 2, -h / 2, w, h); + ctx.setTransform(1, 0, 0, 1, 0, 0); + } + ctx.restore(); + } + } + + // Herhangi bir 'back' / 'front' katmanında emitör var mı? (boş karelerde atla) + hasLayer(layer) { + return this.items.some((it) => (it.cfg.layer || 'front') === layer); + } + } + + window.SVSprites = SpriteSystem; +})(); diff --git a/src/visualizer/visualizer.css b/src/visualizer/visualizer.css index 905fe66..27bdf71 100644 --- a/src/visualizer/visualizer.css +++ b/src/visualizer/visualizer.css @@ -8,7 +8,7 @@ html, body { cursor: none; } -#gl, #c2d { +#gl, #c2d, #fxBack, #fxFront { position: fixed; inset: 0; width: 100%; @@ -17,11 +17,13 @@ html, body { } #gl { z-index: 1; } -#c2d { z-index: 2; } +#fxBack { z-index: 2; } +#c2d { z-index: 3; } +#fxFront { z-index: 4; } #logo { position: fixed; - z-index: 3; + z-index: 5; left: 50%; top: 50%; transform: translate(-50%, -50%); diff --git a/src/visualizer/visualizer.js b/src/visualizer/visualizer.js index a043d3b..25c6603 100644 --- a/src/visualizer/visualizer.js +++ b/src/visualizer/visualizer.js @@ -4,12 +4,18 @@ (function () { const glCanvas = document.getElementById('gl'); const c2d = document.getElementById('c2d'); + const fxBack = document.getElementById('fxBack'); + const fxFront = document.getElementById('fxFront'); + const fxBackCtx = fxBack.getContext('2d'); + const fxFrontCtx = fxFront.getContext('2d'); const logoImg = document.getElementById('logo'); const hint = document.getElementById('hint'); const errBox = document.getElementById('error'); let cfg = window.SV.defaultConfig(); const audio = new window.SVAudio(); + const sprites = new window.SVSprites(); // ek görsel nesneler / partiküller + let imagesOn = false; let gradient = null; // WebGL modu let foreground = null; // 2D modu örneği @@ -33,6 +39,14 @@ c2d.style.width = w + 'px'; c2d.style.height = h + 'px'; + // Ek görsel nesne (partikül) katmanları — ön katmanla aynı çözünürlük + for (const cv of [fxBack, fxFront]) { + cv.width = c2d.width; + cv.height = c2d.height; + cv.style.width = w + 'px'; + cv.style.height = h + 'px'; + } + // WebGL arkaplan: performans için renderScale if (gradient) { const rs = clamp(cfg.power.renderScale, 0.4, 1); @@ -85,6 +99,20 @@ } } + // -------------------------------------------------------------------------- + // Ek görsel nesneler / partiküller + // -------------------------------------------------------------------------- + function applyImages() { + imagesOn = !!(cfg.images && cfg.images.enabled); + sprites.setItems(imagesOn ? cfg.images.items : []); + fxBack.style.display = imagesOn ? 'block' : 'none'; + fxFront.style.display = imagesOn ? 'block' : 'none'; + if (!imagesOn) { + fxBackCtx.clearRect(0, 0, fxBack.width, fxBack.height); + fxFrontCtx.clearRect(0, 0, fxFront.width, fxFront.height); + } + } + // -------------------------------------------------------------------------- // Logo // -------------------------------------------------------------------------- @@ -140,6 +168,17 @@ } } + // ek görsel nesneler / partiküller (arka katman görselin arkasında, + // ön katman görselin önünde) + if (imagesOn) { + fxBackCtx.clearRect(0, 0, fxBack.width, fxBack.height); + fxFrontCtx.clearRect(0, 0, fxFront.width, fxFront.height); + if (!silent && audio.ready) { + if (sprites.hasLayer('back')) sprites.draw(fxBackCtx, audio, t, fxBack.width, fxBack.height, 'back'); + if (sprites.hasLayer('front')) sprites.draw(fxFrontCtx, audio, t, fxFront.width, fxFront.height, 'front'); + } + } + // logo nabzı if (cfg.logo.enabled && cfg.logo.src) { const pulse = 1 + audio.bass * cfg.logo.pulse; @@ -175,6 +214,7 @@ applyBackground(); applyForeground(); + applyImages(); applyLogo(); document.body.style.cursor = cfg.power.hideCursor ? 'none' : 'default';