From 1e321be310d24865dc532f2f29f0d0f46c6b1ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 24 Jun 2026 14:21:29 +0300 Subject: [PATCH] MP3/ses dosyasindan kayipsiz video disa aktarma (CPU/GPU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yeni "Video Disa Aktar" ozelligi: secilen bir ses dosyasi, mevcut gorsel ayarlarla kayipsiz H.264 MP4'e donusturulur. Ekran/ses kaydi yapilmaz — gizli bir render penceresi her kareyi offline ve deterministik cizer, FFT'yi loopback-helper.js ile birebir ayni olcekte hesaplar, ham RGBA kareleri geri basincli IPC ile ana surece akitir; ffmpeg-static ile yuv420p H.264'e kodlanir ve ses kaynaktan -c:a copy ile birebir gomulur. - CPU (libx264) / GPU (NVIDIA NVENC) kodlayici secimi; NVENC fonksiyonel olarak algilanir, yoksa otomatik CPU'ya duser. - Cozunurluk (720p-4K), FPS (30/60), kalite kademeleri ve ilerleme/iptal. - ffmpeg-static bagimliligi eklendi ve asarUnpack'e dahil edildi. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 84 ++++++++++- package.json | 6 +- src/admin/admin.css | 19 +++ src/admin/admin.js | 188 +++++++++++++++++++++++++ src/exporter/exporter.js | 256 +++++++++++++++++++++++++++++++++ src/exporter/index.html | 30 ++++ src/main/main.js | 266 ++++++++++++++++++++++++++++++++++- src/main/preload-admin.js | 9 ++ src/main/preload-exporter.js | 18 +++ src/shared/defaults.js | 9 ++ 10 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 src/exporter/exporter.js create mode 100644 src/exporter/index.html create mode 100644 src/main/preload-exporter.js diff --git a/package-lock.json b/package-lock.json index cfd5a98..8f46df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "audify": "^1.10.1" + "audify": "^1.10.1", + "ffmpeg-static": "^5.3.0" }, "devDependencies": { "@electron/packager": "^20.0.0", @@ -19,6 +20,21 @@ "sharp": "^0.34.5" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -2049,7 +2065,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -2195,6 +2210,12 @@ "node": ">= 0.4" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2428,6 +2449,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2999,7 +3035,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3178,6 +3213,22 @@ } } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3726,6 +3777,21 @@ "node": ">= 14" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -4465,6 +4531,11 @@ "node": ">=0.10.0" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4681,7 +4752,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5499,6 +5569,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", diff --git a/package.json b/package.json index 3c82ef5..423c635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "soundvisualizer", - "version": "1.0.0", + "version": "1.0.1", "description": "CAYADEV — çoklu ekran ve çoklu kaynak destekli ses görselleştirici (Windows + macOS)", "main": "src/main/main.js", "author": { @@ -29,7 +29,8 @@ "sharp": "^0.34.5" }, "dependencies": { - "audify": "^1.10.1" + "audify": "^1.10.1", + "ffmpeg-static": "^5.3.0" }, "build": { "appId": "com.cayadev.visualizer", @@ -48,6 +49,7 @@ "node_modules/audify/**", "node_modules/bindings/**", "node_modules/file-uri-to-path/**", + "node_modules/ffmpeg-static/**", "src/main/loopback-helper.js" ], "win": { diff --git a/src/admin/admin.css b/src/admin/admin.css index cea38ce..d871e56 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -182,6 +182,25 @@ 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; } +/* ---------- Video dışa aktarma ---------- */ +.export-filename { + margin-top: 8px; font-size: 12px; color: var(--muted); + word-break: break-all; padding: 6px 10px; background: var(--card2); + border: 1px solid var(--line); border-radius: 8px; +} +.export-progress { + margin-top: 12px; height: 10px; background: var(--card); + border-radius: 6px; overflow: hidden; border: 1px solid var(--line); +} +.export-progress i { + display: block; height: 100%; width: 0%; + background: linear-gradient(90deg, var(--accent), var(--accent2)); + transition: width .12s linear; +} +.export-status { margin-top: 8px; font-size: 12px; color: var(--muted); word-break: break-all; } +.export-status.ok { color: var(--ok); } +.export-status.err { color: #ff7a7a; } + /* ---------- Alt ---------- */ .footer { display: flex; align-items: center; justify-content: space-between; diff --git a/src/admin/admin.js b/src/admin/admin.js index a892aea..e2c3293 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -9,6 +9,12 @@ let audioDevices = []; let pushTimer = null; + // Video dışa aktarma durumu + let exportAudioPath = null; + let exportAudioName = ''; + let exporting = false; + let gpuAvailable = false; + const $ = (id) => document.getElementById(id); // -------------------------------------------------------------------------- @@ -91,6 +97,10 @@ return buttonCtrl(def); case 'multisource': return multisourceCtrl(def); + case 'audiofile': + return audioFileCtrl(def); + case 'exportpanel': + return exportPanelCtrl(def); default: return null; } @@ -364,6 +374,53 @@ list, ]); } + + // --- Video dışa aktarma: ses dosyası seçici --- + function audioFileCtrl() { + const name = el('div', { + class: 'export-filename', + id: 'exportAudioName', + text: exportAudioName || 'Henüz dosya seçilmedi', + }); + const btn = el('label', { class: 'filebtn', text: '🎵 Ses Dosyası Seç (MP3 / WAV / FLAC)' }); + btn.addEventListener('click', async () => { + if (exporting) return; + const p = await window.api.pickExportAudio(); + if (p) { + exportAudioPath = p; + exportAudioName = p.split(/[\\/]/).pop(); + name.textContent = exportAudioName; + } + }); + return el('div', { class: 'ctrl' }, [btn, name]); + } + + // --- Video dışa aktarma: çalıştır düğmesi + ilerleme --- + function exportPanelCtrl() { + const runBtn = el('button', { + class: 'btn primary', id: 'exportRunBtn', text: '🎬 Videoya Aktar', + onclick: () => actions.runExport(), + }); + const cancelBtn = el('button', { + class: 'btn ghost small', id: 'exportCancelBtn', text: '■ İptal', + onclick: () => window.api.cancelExport(), + }); + cancelBtn.style.display = 'none'; + cancelBtn.style.marginLeft = '8px'; + + const fill = el('i', { id: 'exportProgressFill' }); + const bar = el('div', { class: 'export-progress', id: 'exportProgressBar' }, [fill]); + bar.style.display = 'none'; + + const status = el('div', { class: 'export-status', id: 'exportStatus' }); + + return el('div', { class: 'ctrl' }, [ + el('div', { class: 'row' }, [runBtn, cancelBtn]), + bar, + status, + ]); + } + // -------------------------------------------------------------------------- // Bölüm şeması // -------------------------------------------------------------------------- @@ -491,6 +548,49 @@ { type: 'toggle', path: 'power.hideCursor', label: 'İmleci Gizle' }, ], }, + { + icon: '🎬', + title: 'Video Dışa Aktar (MP3 → Video)', + desc: 'Bir ses dosyası seç; yukarıdaki görsel ayarlarla kayıpsız videoya dönüştürülür. Ekran/ses kaydı yapılmaz — her kare birebir render edilir, ses kaynaktan kopyalanır.', + controls: [ + { type: 'audiofile' }, + { + type: 'select', path: 'export.resolution', label: 'Çözünürlük', + options: [ + { value: '720p', label: '720p — 1280×720' }, + { value: '1080p', label: '1080p — 1920×1080 (Full HD)' }, + { value: '1440p', label: '1440p — 2560×1440 (2K)' }, + { value: '2160p', label: '2160p — 3840×2160 (4K)' }, + ], + }, + { + type: 'select', path: 'export.fps', label: 'Kare Hızı (FPS)', numeric: true, + options: [ + { value: 30, label: '30 FPS' }, + { value: 60, label: '60 FPS (Akıcı)' }, + ], + }, + { + type: 'select', path: 'export.encoder', label: 'Kodlayıcı (Hız)', + options: () => + gpuAvailable + ? [ + { value: 'gpu', label: '⚡ GPU — NVIDIA NVENC (çok hızlı)' }, + { value: 'cpu', label: 'CPU — libx264 (en uyumlu, yavaş)' }, + ] + : [{ value: 'cpu', label: 'CPU — libx264 (GPU bulunamadı)' }], + }, + { + type: 'select', path: 'export.quality', label: 'Kalite', + options: [ + { value: 'visually-lossless', label: 'Görsel Kayıpsız (en yüksek)' }, + { value: 'high', label: 'Yüksek' }, + { value: 'balanced', label: 'Dengeli (daha küçük dosya)' }, + ], + }, + { type: 'exportpanel' }, + ], + }, ]; } @@ -564,12 +664,71 @@ render(); }; + // -------------------------------------------------------------------------- + // Video dışa aktarma + // -------------------------------------------------------------------------- + function setExportStatus(text, cls) { + const s = $('exportStatus'); + if (!s) return; + s.textContent = text || ''; + s.className = 'export-status' + (cls ? ' ' + cls : ''); + } + + function setExportUI(active) { + const run = $('exportRunBtn'); + const cancel = $('exportCancelBtn'); + const bar = $('exportProgressBar'); + if (run) run.disabled = active; + if (cancel) cancel.style.display = active ? 'inline-flex' : 'none'; + if (bar) bar.style.display = active ? 'block' : 'none'; + if (!active) { + const fill = $('exportProgressFill'); + if (fill) fill.style.width = '0%'; + } + } + + actions.runExport = async () => { + if (exporting) return; + if (!exportAudioPath) { + setExportStatus('Önce bir ses dosyası seçin.', 'err'); + return; + } + const base = (exportAudioName || 'gorsellestirme').replace(/\.[^.]+$/, ''); + const outputPath = await window.api.pickExportOutput(base + '.mp4'); + if (!outputPath) return; + + push(true); // en güncel görsel ayarları ana sürece gönder + + const r = await window.api.startExport({ + audioPath: exportAudioPath, + outputPath, + resolution: cfg.export.resolution, + fps: cfg.export.fps, + quality: cfg.export.quality, + encoder: cfg.export.encoder, + }); + if (!r || !r.ok) { + setExportStatus('⚠ ' + ((r && r.error) || 'Başlatılamadı'), 'err'); + return; + } + exporting = true; + setExportUI(true); + setExportStatus('Hazırlanıyor…'); + }; + async function init() { const saved = await window.api.getSettings(); if (saved) cfg = window.SV.deepMerge(window.SV.defaultConfig(), saved); displays = await window.api.getDisplays(); audioDevices = (await window.api.getOutputDevices()) || []; + + // GPU (NVENC) kodlayıcı var mı? Yoksa CPU'ya zorla. + try { gpuAvailable = !!(await window.api.gpuAvailable()); } catch { gpuAvailable = false; } + if (!gpuAvailable && cfg.export && cfg.export.encoder === 'gpu') { + cfg.export.encoder = 'cpu'; + } + renderDisplays(); render(); @@ -624,6 +783,35 @@ $('bannerClose').addEventListener('click', () => $('banner').classList.add('hidden')); + // Video dışa aktarma olayları + let exportEnc = ''; + window.api.onExportProgress((d) => { + if (d.phase === 'start') { + exportEnc = d.encoder === 'gpu' ? 'GPU/NVENC' : 'CPU/libx264'; + return; + } + if (d.phase === 'encode') { + setExportStatus('Kodlanıyor (' + exportEnc + ')… kareler bitti, video yazılıyor.'); + return; + } + const pct = d.total ? Math.round((d.done / d.total) * 100) : 0; + const fill = $('exportProgressFill'); + if (fill) fill.style.width = pct + '%'; + setExportStatus('Render ediliyor [' + exportEnc + ']… %' + pct + ' (' + d.done + ' / ' + d.total + ' kare)'); + }); + window.api.onExportDone((d) => { + exporting = false; + setExportUI(false); + if (d.status === 'done') { + const enc = d.encoder === 'gpu' ? 'GPU/NVENC' : 'CPU/libx264'; + setExportStatus('✅ Tamamlandı (' + enc + ') → ' + d.output, 'ok'); + } else if (d.status === 'cancelled') { + setExportStatus('İptal edildi.'); + } else { + setExportStatus('⚠ Hata: ' + (d.message || 'bilinmeyen hata'), 'err'); + } + }); + // Açılışta ana sürece de gönder (kalıcılık + senkron) push(true); } diff --git a/src/exporter/exporter.js b/src/exporter/exporter.js new file mode 100644 index 0000000..6c0bd0c --- /dev/null +++ b/src/exporter/exporter.js @@ -0,0 +1,256 @@ +'use strict'; +/* + * Offline (deterministik) video render motoru. + * Ekran/ses KAYDI YAPMAZ: seçilen ses dosyasını çözer, her video karesi için + * FFT'yi birebir hesaplar (loopback-helper.js ile aynı ölçek) ve görselleştiriciyle + * AYNI efektleri (WebGL gradyan + 2D ön katman + logo) kare kare çizer. + * Ham RGBA kareler ana sürece akıtılır; ffmpeg orada kayıpsız videoya kodlar. + */ +(function () { + // ---- loopback-helper.js ile birebir aynı sabitler/ölçek ---- + const FFT_SIZE = 2048; + const BINS = FFT_SIZE / 2; // 1024 + const MIN_DB = -90; + const MAX_DB = -18; + + const hann = new Float32Array(FFT_SIZE); + for (let i = 0; i < FFT_SIZE; i++) { + hann[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (FFT_SIZE - 1))); + } + const re = new Float32Array(FFT_SIZE); + const im = new Float32Array(FFT_SIZE); + + // loopback-helper.js'teki radix-2 FFT (birebir) + function fft() { + const n = FFT_SIZE; + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + const tr = re[i]; re[i] = re[j]; re[j] = tr; + const ti = im[i]; im[i] = im[j]; im[j] = ti; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = (-2 * Math.PI) / len; + const wr = Math.cos(ang), wi = Math.sin(ang); + const half = len >> 1; + for (let i = 0; i < n; i += len) { + let cr = 1, ci = 0; + for (let k = 0; k < half; k++) { + const a = i + k, b = a + half; + const vr = re[b] * cr - im[b] * ci; + const vi = re[b] * ci + im[b] * cr; + re[b] = re[a] - vr; im[b] = im[a] - vi; + re[a] += vr; im[a] += vi; + const ncr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = ncr; + } + } + } + } + + // ---- Durum ---- + const glCanvas = document.getElementById('gl'); + const c2d = document.getElementById('c2d'); + const comp = document.getElementById('comp'); + // Her karede getImageData ile geri okuma yapıldığı için willReadFrequently açık. + const compCtx = comp.getContext('2d', { willReadFrequently: true }); + + const audio = new window.SVAudio(); + let cfg = null; + let gradient = null; + let foreground = null; + let logo = null; + + let pcm = null; + let totalSamples = 0; + let sampleRate = 48000; + let width = 1920; + let height = 1080; + let fps = 60; + let totalFrames = 1; + + const ring = new Float32Array(FFT_SIZE); + const freqBytes = new Uint8Array(BINS); + const timeBytes = new Uint8Array(FFT_SIZE); + + function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } + + // Belirli bir örnek konumunda (sampleEnd ile biten 2048 örneklik pencere) analiz et. + // Çıktı: freqBytes (0..255, dB ölçekli) + timeBytes (128 merkez) — loopback ile aynı. + function analyzeAt(sampleEnd) { + const start = sampleEnd - FFT_SIZE; + for (let i = 0; i < FFT_SIZE; i++) { + const idx = start + i; + ring[i] = idx >= 0 && idx < totalSamples ? pcm[idx] : 0; + } + for (let i = 0; i < FFT_SIZE; i++) { + re[i] = ring[i] * hann[i]; + im[i] = 0; + } + fft(); + const range = MAX_DB - MIN_DB; + for (let i = 0; i < BINS; i++) { + const mag = Math.sqrt(re[i] * re[i] + im[i] * im[i]) / (FFT_SIZE / 4); + let db = 20 * Math.log10(mag + 1e-9); + let v = (db - MIN_DB) / range; + v = v < 0 ? 0 : v > 1 ? 1 : v; + freqBytes[i] = (v * 255) | 0; + } + for (let i = 0; i < FFT_SIZE; i++) { + let s = ring[i]; + s = s < -1 ? -1 : s > 1 ? 1 : s; + timeBytes[i] = (128 + s * 127) | 0; + } + } + + // Ses dosyasını PCM'e çöz (kayıpsız çözümleme). 48000 Hz sabit — canlı boru hattıyla + // aynı binHz (48000/2048) için. Kanallar mono'ya indirgenir (loopback ile aynı). + async function decodeAudio(arrayBuffer) { + const Ctx = window.AudioContext || window.webkitAudioContext; + const ac = new Ctx({ sampleRate: 48000 }); + const buf = await ac.decodeAudioData(arrayBuffer); + try { ac.close(); } catch (e) {} + sampleRate = buf.sampleRate; + const n = buf.length; + const ch = buf.numberOfChannels; + pcm = new Float32Array(n); + if (ch <= 1) { + pcm.set(buf.getChannelData(0)); + } else { + const a = buf.getChannelData(0); + const b = buf.getChannelData(1); + for (let i = 0; i < n; i++) pcm[i] = (a[i] + b[i]) * 0.5; + } + totalSamples = n; + return buf.duration; + } + + function loadLogo(src) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); + } + + async function setup(job) { + cfg = window.SV.deepMerge(window.SV.defaultConfig(), job.cfg || {}); + width = job.width; + height = job.height; + fps = job.fps; + + glCanvas.width = width; + glCanvas.height = height; + c2d.width = width; + c2d.height = height; + comp.width = width; + comp.height = height; + + // Arkaplan (gradyan WebGL). Dışa aktarımda tam çözünürlük (renderScale yok sayılır). + if (cfg.background.type === 'gradient') { + gradient = new window.SVModes.gradient(glCanvas); + gradient.resize(width, height); + } + + // Ön görselleştirici + const type = cfg.visualizer.type; + if (type && type !== 'none' && window.SVModes[type]) { + foreground = new window.SVModes[type](c2d); + if (foreground.resize) foreground.resize(); + } + + audio.applyConfig(cfg.audio); + + if (cfg.logo.enabled && cfg.logo.src) { + logo = await loadLogo(cfg.logo.src); + } + } + + function drawLogo() { + if (!logo || !cfg.logo.enabled) return; + const l = cfg.logo; + const minDim = Math.min(width, height); + const size = minDim * clamp(l.scale, 0.03, 0.9); + const pulse = 1 + audio.bass * l.pulse; + const w = size * pulse; + const ratio = logo.naturalHeight && logo.naturalWidth ? logo.naturalHeight / logo.naturalWidth : 1; + const h = w * ratio; + const cx = l.x * width; + const cy = l.y * height; + compCtx.save(); + compCtx.globalAlpha = clamp(l.opacity, 0, 1); + if (l.glow > 0) { + compCtx.shadowColor = 'rgba(255,255,255,0.6)'; + compCtx.shadowBlur = l.glow * 40 * (minDim / 1080); + } + compCtx.drawImage(logo, cx - w / 2, cy - h / 2, w, h); + compCtx.restore(); + } + + // Tek kareyi çiz ve birleştirme yüzeyinin RGBA baytlarını döndür. + function renderFrame(i) { + const t = i / fps; + const dt = 1 / fps; + const sampleEnd = Math.round(t * sampleRate); + + analyzeAt(sampleEnd); + audio.ingestFrame({ freq: freqBytes, time: timeBytes, sampleRate }); + audio.update(); + + // Arkaplan + if (cfg.background.type === 'gradient' && gradient) { + gradient.draw(audio, cfg, t); + compCtx.drawImage(glCanvas, 0, 0, width, height); + } else { + compCtx.fillStyle = cfg.background.solidColor; + compCtx.fillRect(0, 0, width, height); + } + + // Ön görselleştirici (kendi kanvasını temizleyip çizer) + if (foreground) { + foreground.draw(audio, cfg, t, dt); + compCtx.drawImage(c2d, 0, 0, width, height); + } + + // Logo + drawLogo(); + + return compCtx.getImageData(0, 0, width, height).data; // Uint8ClampedArray RGBA + } + + async function run(job) { + const duration = await decodeAudio(job.audioBuffer); + await setup(job); + totalFrames = Math.max(1, Math.ceil(duration * fps)); + window.exp.ready(totalFrames); + + const progStep = Math.max(1, Math.round(fps / 4)); + for (let i = 0; i < totalFrames; 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; + } + if (i % progStep === 0) window.exp.progress(i + 1, totalFrames); + } + window.exp.progress(totalFrames, totalFrames); + window.exp.finish(); + } + + window.exp.onJob((job) => { + let ab = job.audioBuffer; + // Node Buffer/Uint8Array geldiyse ArrayBuffer'a normalize et + if (ab && ab.buffer && ab.byteLength != null && !(ab instanceof ArrayBuffer)) { + ab = ab.buffer.slice(ab.byteOffset, ab.byteOffset + ab.byteLength); + } + job.audioBuffer = ab; + run(job).catch((e) => window.exp.error(String((e && e.stack) || e))); + }); +})(); diff --git a/src/exporter/index.html b/src/exporter/index.html new file mode 100644 index 0000000..18f94e4 --- /dev/null +++ b/src/exporter/index.html @@ -0,0 +1,30 @@ + + + + + + Dışa Aktarma Render + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/main.js b/src/main/main.js index a4dcd78..bd6cf4f 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -5,9 +5,10 @@ * MIT License (bkz. LICENSE) */ -const { app, BrowserWindow, ipcMain, screen } = require('electron'); +const { app, BrowserWindow, ipcMain, screen, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); +const { spawn } = require('child_process'); const nativeAudio = require('./native-audio'); // ---------------------------------------------------------------------------- @@ -281,6 +282,269 @@ ipcMain.on('report-audio-devices', (e, devices) => { notifyAdmin('audio-devices', devices); }); +// ---------------------------------------------------------------------------- +// Video Dışa Aktarma (MP3/ses -> kayıpsız video) +// Ekran/ses KAYDI yok: gizli bir render penceresi her kareyi offline ve birebir +// çizer, ham RGBA kareleri buraya akıtır; ffmpeg görsel-kayıpsız H.264 MP4'e kodlar +// ve kaynak sesi olduğu gibi (yeniden kodlamadan) videoya gömer. +// ---------------------------------------------------------------------------- +let exportWin = null; +let ffmpegProc = null; +let exportState = null; // { outputPath, ffErr } — aktifken dolu, biter bitmez null + +// Paketlenmiş ffmpeg (ffmpeg-static); yoksa sistem PATH'indeki "ffmpeg". +function resolveFfmpeg() { + try { + let p = require('ffmpeg-static'); + if (p) { + p = p.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep); + if (fs.existsSync(p)) return p; + } + } catch {} + return 'ffmpeg'; +} + +const RES_MAP = { + '720p': [1280, 720], + '1080p': [1920, 1080], + '1440p': [2560, 1440], + '2160p': [3840, 2160], +}; +const CRF_MAP = { 'visually-lossless': 14, high: 18, balanced: 22 }; // libx264 (CPU) +const CQ_MAP = { 'visually-lossless': 16, high: 20, balanced: 25 }; // h264_nvenc (GPU) + +// NVIDIA NVENC donanım kodlayıcısının bu makinede gerçekten çalışıp çalışmadığını +// fonksiyonel olarak doğrula (kodlayıcının listede olması yetmez — sürücü/GPU şart). +// Sonuç önbelleğe alınır. +let _gpuCache = null; +function detectGpuEncoder() { + if (_gpuCache !== null) return Promise.resolve(_gpuCache); + return new Promise((resolve) => { + let done = false; + const finish = (v) => { if (done) return; done = true; _gpuCache = v; resolve(v); }; + let p; + try { + p = spawn(resolveFfmpeg(), [ + '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=size=256x256:rate=30:duration=1', + '-c:v', 'h264_nvenc', '-preset', 'p7', '-cq', '19', '-f', 'null', '-', + ], { windowsHide: true }); + } catch { + return finish(false); + } + p.on('error', () => finish(false)); + p.on('exit', (code) => finish(code === 0)); + setTimeout(() => { try { p.kill(); } catch {} finish(false); }, 10000); + }); +} + +// 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) { + if (encoder === 'gpu') { + const cq = CQ_MAP[quality] != null ? CQ_MAP[quality] : 16; + return [ + // p5 = kalite-dengeli NVENC preset'i; p7'den belirgin hızlı, kalite ~aynı. + '-c:v', 'h264_nvenc', '-preset', 'p5', '-tune', 'hq', + '-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']; +} + +ipcMain.handle('export:gpu-available', () => detectGpuEncoder()); + +ipcMain.handle('export:pick-audio', async () => { + const r = await dialog.showOpenDialog(adminWin, { + title: 'Ses Dosyası Seç', + properties: ['openFile'], + filters: [ + { name: 'Ses Dosyaları', extensions: ['mp3', 'wav', 'flac', 'm4a', 'aac', 'ogg', 'opus'] }, + { name: 'Tümü', extensions: ['*'] }, + ], + }); + if (r.canceled || !r.filePaths[0]) return null; + return r.filePaths[0]; +}); + +ipcMain.handle('export:pick-output', async (e, defaultName) => { + const r = await dialog.showSaveDialog(adminWin, { + title: 'Videoyu Kaydet', + defaultPath: defaultName || 'gorsellestirme.mp4', + filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], + }); + if (r.canceled || !r.filePath) return null; + return r.filePath; +}); + +ipcMain.handle('export:start', async (e, opts) => { + if (exportState) return { ok: false, error: 'Zaten bir dışa aktarma sürüyor.' }; + opts = opts || {}; + const audioPath = opts.audioPath; + const outputPath = opts.outputPath; + if (!audioPath || !fs.existsSync(audioPath)) return { ok: false, error: 'Ses dosyası bulunamadı.' }; + if (!outputPath) return { ok: false, error: 'Çıktı yolu seçilmedi.' }; + + const [w, h] = RES_MAP[opts.resolution] || RES_MAP['1080p']; + const fps = opts.fps === 30 ? 30 : 60; + + // 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); + + // Ses akışını kayıpsız kopyala (mp4 uyumlu kodek). Değilse şeffaf AAC'e dön. + const ext = path.extname(audioPath).toLowerCase(); + const audioCopy = ['.mp3', '.m4a', '.aac'].includes(ext); + const audioArgs = audioCopy ? ['-c:a', 'copy'] : ['-c:a', 'aac', '-b:a', '320k']; + + const args = [ + '-y', + '-f', 'rawvideo', + '-pixel_format', 'rgba', + '-video_size', `${w}x${h}`, + '-framerate', String(fps), + '-i', 'pipe:0', + '-i', audioPath, + '-map', '0:v:0', + '-map', '1:a:0', + ...videoArgs, + ...audioArgs, + '-movflags', '+faststart', + '-shortest', + outputPath, + ]; + + const ff = resolveFfmpeg(); + let audioBuf; + try { + audioBuf = fs.readFileSync(audioPath); + } catch (err) { + return { ok: false, error: 'Ses dosyası okunamadı: ' + err.message }; + } + + try { + ffmpegProc = spawn(ff, args, { windowsHide: true }); + } catch (err) { + return { ok: false, error: 'ffmpeg başlatılamadı: ' + err.message }; + } + + exportState = { outputPath, ffErr: '', encoder }; + notifyAdmin('export-progress', { phase: 'start', encoder }); + ffmpegProc.stderr.on('data', (d) => { + if (!exportState) return; + exportState.ffErr += d.toString(); + if (exportState.ffErr.length > 8000) exportState.ffErr = exportState.ffErr.slice(-8000); + }); + ffmpegProc.stdin.on('error', () => {}); // iptal/kapanışta EPIPE'i yut + ffmpegProc.on('error', (err) => finalizeExport('error', 'ffmpeg hatası: ' + err.message)); + ffmpegProc.on('exit', (code) => { + // İş bitti (stdin kapandı) ve ffmpeg başarıyla çıktıysa -> tamam. + if (!exportState) return; // zaten finalize edilmiş (iptal/hata) + if (code === 0) finalizeExport('done'); + else finalizeExport('error', 'ffmpeg çıkış kodu ' + code + '\n' + (exportState.ffErr || '').slice(-1200)); + }); + + // Gizli render penceresi (offscreen kanvas; ekrana çizilmez) + exportWin = new BrowserWindow({ + width: 320, + height: 200, + show: false, + skipTaskbar: true, + webPreferences: { + preload: path.join(__dirname, 'preload-exporter.js'), + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); + attachSmoke(exportWin, 'EXPORT'); + exportWin.on('closed', () => { exportWin = null; }); + + try { + await exportWin.loadFile(path.join(__dirname, '..', 'exporter', 'index.html')); + } catch (err) { + finalizeExport('error', 'Render penceresi yüklenemedi: ' + err.message); + return { ok: false, error: 'Render penceresi yüklenemedi.' }; + } + + const ab = audioBuf.buffer.slice(audioBuf.byteOffset, audioBuf.byteOffset + audioBuf.byteLength); + exportWin.webContents.send('export:job', { + audioBuffer: ab, + width: w, + height: h, + fps, + cfg: currentConfig || {}, + }); + + return { ok: true }; +}); + +ipcMain.handle('export:cancel', () => { + if (exportState) exportState.cancel = true; + return true; +}); + +// Bir RGBA kareyi ffmpeg.stdin'e yaz; geri basınç için "drain"i bekle. +ipcMain.handle('export:frame', async (e, data) => { + if (!exportState || exportState.cancel || !ffmpegProc || !ffmpegProc.stdin.writable) { + return { cancel: true }; + } + const buf = Buffer.from(data); // RGBA baytları (kopyalanır) + const ok = ffmpegProc.stdin.write(buf); + if (!ok) { + await new Promise((res) => { + const s = ffmpegProc && ffmpegProc.stdin; + if (!s) return res(); + s.once('drain', res); + }); + } + return { cancel: !!(exportState && exportState.cancel) }; +}); + +ipcMain.on('export:ready', (e, total) => { + notifyAdmin('export-progress', { phase: 'render', done: 0, total }); +}); +ipcMain.on('export:progress', (e, p) => { + notifyAdmin('export-progress', { phase: 'render', done: p.done, total: p.total }); +}); +ipcMain.on('export:finish', () => { + // Tüm kareler yazıldı: stdin'i kapat -> ffmpeg kalan kodlamayı bitirip 'exit' verir. + notifyAdmin('export-progress', { phase: 'encode' }); + if (ffmpegProc && ffmpegProc.stdin.writable) { + try { ffmpegProc.stdin.end(); } catch {} + } +}); +ipcMain.on('export:cancelled', () => finalizeExport('cancelled')); +ipcMain.on('export:error', (e, msg) => finalizeExport('error', msg)); + +function finalizeExport(status, message) { + if (!exportState) return; // tek sefer + const out = exportState.outputPath; + const encoder = exportState.encoder; + exportState = null; + + if (status !== 'done' && ffmpegProc) { + try { ffmpegProc.stdin.destroy(); } catch {} + try { ffmpegProc.kill(); } catch {} + } + ffmpegProc = null; + + if (exportWin && !exportWin.isDestroyed()) { + try { exportWin.destroy(); } catch {} + } + exportWin = null; + + // Yarım kalan dosyayı temizle (iptal/hata) + if (status !== 'done' && out) { + setTimeout(() => { try { fs.existsSync(out) && fs.unlinkSync(out); } catch {} }, 200); + } + + notifyAdmin('export-done', { status, output: out, message: message || '', encoder }); +} + // ---------------------------------------------------------------------------- // Uygulama yaşam döngüsü // ---------------------------------------------------------------------------- diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index fb0f6ba..26e05a4 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -14,6 +14,15 @@ contextBridge.exposeInMainWorld('api', { closeVisualizer: () => ipcRenderer.invoke('close-visualizer'), updateConfig: (config) => ipcRenderer.send('update-config', config), + // Video dışa aktarma + gpuAvailable: () => ipcRenderer.invoke('export:gpu-available'), + pickExportAudio: () => ipcRenderer.invoke('export:pick-audio'), + pickExportOutput: (name) => ipcRenderer.invoke('export:pick-output', name), + startExport: (opts) => ipcRenderer.invoke('export:start', opts), + cancelExport: () => ipcRenderer.invoke('export:cancel'), + onExportProgress: (cb) => ipcRenderer.on('export-progress', (e, d) => cb(d)), + onExportDone: (cb) => ipcRenderer.on('export-done', (e, d) => cb(d)), + // Olaylar (ana süreç -> admin) onVisualizerStatus: (cb) => ipcRenderer.on('visualizer-status', (e, data) => cb(data)), diff --git a/src/main/preload-exporter.js b/src/main/preload-exporter.js new file mode 100644 index 0000000..2cd5542 --- /dev/null +++ b/src/main/preload-exporter.js @@ -0,0 +1,18 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('exp', { + // Ana süreç -> render: iş tanımı (ses + boyut + fps + yapılandırma) + onJob: (cb) => ipcRenderer.on('export:job', (e, job) => cb(job)), + + // render -> ana süreç + ready: (totalFrames) => ipcRenderer.send('export:ready', totalFrames), + // Bir RGBA kare gönder; ana süreç ffmpeg.stdin'e yazana kadar bekler (geri basınç). + // Dönen { cancel: true } ise render durdurulur. + sendFrame: (data, index) => ipcRenderer.invoke('export:frame', data, index), + progress: (done, total) => ipcRenderer.send('export:progress', { done, total }), + finish: () => ipcRenderer.send('export:finish'), + cancelled: () => ipcRenderer.send('export:cancelled'), + error: (msg) => ipcRenderer.send('export:error', msg), +}); diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 632cfec..b6704b8 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -71,6 +71,15 @@ pauseOnSilence: false, hideCursor: true, }, + + // 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: { + resolution: '1080p', // '720p' | '1080p' | '1440p' | '2160p' + fps: 60, // 30 | 60 + quality: 'visually-lossless', // 'visually-lossless' | 'high' | 'balanced' + encoder: 'gpu', // 'gpu' (NVIDIA NVENC, hızlı) | 'cpu' (libx264, en uyumlu) + }, }; // Hazır renk şablonları (arkaplan gradyanı)