From dcd09699aba0fca3dce14b753be522236c5435df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 17:52:56 +0300 Subject: [PATCH 1/2] perf(presets): keep large preset libraries light Importing a ZIP or a library found on the machine means thousands of presets, and the store could not carry them. Measured with the corpus (10,347 MilkDrop presets, 116 MB) in an isolated copy: presets:list took 3.8 s, a single save held the main process for about 2.4 s, the panel used 631 MB, and every web client received 116 MB on every change. The store now reads the files once, in the background at start-up, and keeps them; saves and deletes update the cache, and list() only compares the folder's file names, so files added or removed by hand still show up. Bulk saves are written in batches with pauses, report progress and keep the pack's own order. A save or delete sends only what changed (presets-delta); the full list is sent only when a page opens. Pages apply changes in the store's order, newest first with ties broken by id, so presets saved in the same millisecond no longer land in a machine-dependent order. Web clients get MilkDrop presets without their sources. The overlay engine fetches a source by id (/milkdrop/preset, behind the token) only when it is about to draw that preset, keeps the current one on screen until it arrives, and drops the pick if it cannot be fetched. Found on the way: every preset change rebuilt the visualizer window's whole layer stack, so importing or deleting a MilkDrop preset restarted the MilkDrop picture. The stack is now rebuilt only when a Studio preset changes. The MilkDrop panel no longer keeps a second copy of the list. Same corpus after the change: list 0.3 s, a save 29 ms (91 ms with a window open), panel 273 MB. With 2,000 presets and a web overlay in a real browser, the connect message went from about 30 MB to 271 KB. Refs #574, #560 --- README.md | 12 +- README.tr.md | 12 +- ROADMAP.md | 49 +++- src/admin/admin.js | 8 + src/admin/milkdrop-panel.js | 40 ++- src/admin/studio.js | Bin 30268 -> 30802 bytes src/main/main.js | 47 +++- src/main/preload-admin.js | 3 + src/main/preload-visualizer.js | 2 + src/main/presets-store.js | 181 +++++++++++-- src/main/stream-server.js | 42 ++- src/shared/presets.js | 35 +++ src/visualizer/modes/milkdrop.js | 47 +++- src/visualizer/visualizer.js | 18 ++ src/web/web-shim.js | 31 ++- tests/presets-scale.test.js | 451 +++++++++++++++++++++++++++++++ 16 files changed, 931 insertions(+), 47 deletions(-) create mode 100644 tests/presets-scale.test.js diff --git a/README.md b/README.md index 72c1304..0749eb0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#build--distribution) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Tests](https://img.shields.io/badge/tests-2032%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2050%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -339,6 +339,14 @@ that asserts the bar profile has no step in it. the presets in view with them, and importing the pack attaches them to the new copies. Checked in an isolated copy: with the pool set to three favourites, the visualizer window went round those three and nothing else. +- **Large preset libraries stay light.** Every save or delete used to re-read every preset file + and send the whole list, sources included, to the panel, each window and every web client. With + the 10,347 presets of a full MilkDrop library (116 MB) one delete held the app for about 2.4 + seconds and the panel used 631 MB. Presets are now read once and kept; a change sends only what + changed. At the same size a save now takes 91 ms and the panel uses 273 MB. Deleting or adding + MilkDrop presets no longer restarts the MilkDrop picture on screen. Web clients get MilkDrop + presets without their sources (271 KB instead of about 30 MB for 2,000 presets), and the web + overlay fetches the source of a preset only when it is about to draw it. - **Sprites from `milk_img.ini`.** MilkDrop 2 draws images of your own over the picture during a show: each one is defined in `milk_img.ini` with an image, code that runs once and code that runs every frame, and launched by number. Choose the file in the MilkDrop panel and launch from @@ -1238,7 +1246,7 @@ npm test npm start -- --smoke ``` -**2032 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2050 unit tests, all passing.** They are written to check answers, not to exercise lines: - **Formulas** are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor diff --git a/README.tr.md b/README.tr.md index f974c4c..31ea61f 100644 --- a/README.tr.md +++ b/README.tr.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#paketleme--dağıtım) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Test](https://img.shields.io/badge/test-2032%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2050%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -332,6 +332,14 @@ profilinde basamak olmadığını doğruluyor. üçünü de taşıyor: *Görünenleri Paketle* listede görünen presetleri onlarla birlikte yazıyor, paketi içe aktarmak onları yeni kopyalara bağlıyor. Yalıtılmış bir kopyada denetlendi: havuz üç favoriye ayarlıyken görselleştirici penceresi o üçünde döndü, başka hiçbir presete geçmedi. +- **Büyük preset kütüphaneleri hafif kalıyor.** Her kayıt ya da silme bütün preset dosyalarını + yeniden okuyor ve bütün listeyi kaynaklarıyla panele, her pencereye ve her web istemcisine + gönderiyordu. Tam bir MilkDrop kütüphanesinin 10.347 presetiyle (116 MB) tek bir silme uygulamayı + ~2,4 saniye tutuyordu ve panel 631 MB kullanıyordu. Presetler artık bir kez okunup tutuluyor; bir + değişiklik yalnız değişeni gönderiyor. Aynı büyüklükte bir kayıt artık 91 ms sürüyor, panel 273 MB + kullanıyor. MilkDrop presetleri eklenip silindiğinde ekrandaki MilkDrop görüntüsü artık baştan + başlamıyor. Web istemcileri MilkDrop presetlerini kaynaksız alıyor (2.000 presette ~30 MB yerine + 271 KB) ve web çıkışı bir presetin kaynağını ancak onu çizecekken istiyor. - **`milk_img.ini`'den sprite'lar.** MilkDrop 2 gösteri sırasında kendi resimlerinizi görüntünün üstüne çiziyor: her biri `milk_img.ini`de bir resim, bir kez çalışan ve her kare çalışan koduyla tanımlı ve numarasıyla başlatılıyor. Dosyayı MilkDrop panelinde seçin; panelin listesinden, bir @@ -1235,7 +1243,7 @@ npm test npm start -- --smoke ``` -**2032 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2050 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: - **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı diff --git a/ROADMAP.md b/ROADMAP.md index 76f0a9f..6639bad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2032 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2050 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; 105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at - that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 435 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 453 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1615,6 +1615,51 @@ rest after. No version number yet. longer holds, and the panel drawn with a fake DOM — its own pack buttons included, with only the file dialogs stubbed, and ◀/▶/🎲 still reaching presets outside the pool. 49 of 49 mutations are caught. +- **Large preset libraries (#574, part 1 of 2: scale)** · done. Importing a + ZIP or a library found on the machine means thousands of presets; the + store could not carry them. Measured in an isolated copy with the corpus + (10,347 presets, 116 MB): `presets:list` took 3.8 s, one save held the main + process for about 2.4 s, the panel used 631 MB, and every web client got + 116 MB on every change. The user chose to scale the store before adding + the importer. + - **Store.** Files are read once — in the background at start-up — and + kept; saves and deletes update the cache. `list()` still looks at the + folder's file names on every call, so a file added or removed by hand + shows up; a file edited by hand shows up after a restart. Bulk saves are + written in batches with pauses so the main process keeps serving audio, + lights and IPC, report progress, and keep the pack's own order. + - **Change broadcast.** A save or delete sends only what changed + (`presets-delta`); the whole list is sent only when a page opens. Pages + apply it in the store's order — newest first, ties broken by id, so + presets saved in the same millisecond no longer land in a different + order on each machine. + - **Web clients** get MilkDrop presets without their sources. The overlay + engine asks for a source by id (`/milkdrop/preset`, behind the token) + only when it is about to draw that preset, keeps the current one on + screen until it arrives, and drops the pick if it cannot be fetched + rather than falling back to the default preset. + - **Found on the way:** every preset change rebuilt the visualizer + window's whole layer stack, so importing or deleting a MilkDrop preset + restarted the MilkDrop picture on screen. The stack is now rebuilt only + when a Studio preset changes. The MilkDrop panel no longer keeps a + second copy of the list. + - **Measured** after the change, same corpus: `presets:list` 0.3 s, a + save 29 ms (91 ms with a window open), the panel 273 MB. With 2,000 + presets and a web overlay open in a real browser: the connect message + was 271 KB instead of about 30 MB; the overlay followed the window's + auto advance preset for preset and fetched the sources of the seven it + showed; a delete took 68 ms while the window's MilkDrop kept drawing + (same preset, 97 frames in 1.2 s), and the window, overlay and panel + lists all dropped to 1,999. + - **Not done here:** visualizer windows still hold every source (141 MB + with 10,347 presets), since they draw from them directly; the importer + itself is part 2. + - **Tests.** 18 tests: the store against a temporary folder (one read, + cache updates, hand-made files, background load with a save in between, + batched saves in the pack's order), the change broadcast on the page, + the stream server stripping sources and serving them behind the token, + the web bridge, the visualizer's rebuild rule and the engine waiting + for a source. 23 of 23 mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/src/admin/admin.js b/src/admin/admin.js index 7e5ea82..30e13f8 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -4496,6 +4496,14 @@ render(); if (window.SVPreview) window.SVPreview.setConfig(cfg); }); + // Değişiklik yayını (#574): bütün liste yalnız açılışta geliyor + if (window.api.onPresetsDelta) { + window.api.onPresetsDelta((d) => { + window.SVPresets.applyDelta(d); + render(); + if (window.SVPreview) window.SVPreview.setConfig(cfg); + }); + } // Uzaktan kumandadan (telefon / OBS sayfası) gelen değişiklik: panelin // kendi kopyası tazelenir ve geri gönderilmez — yoksa sonsuz döngü olur. diff --git a/src/admin/milkdrop-panel.js b/src/admin/milkdrop-panel.js index 08a73e9..68994a0 100644 --- a/src/admin/milkdrop-panel.js +++ b/src/admin/milkdrop-panel.js @@ -57,7 +57,31 @@ çizildi. */ let staleRender = false; + /* TEK LİSTE (#574). Panel eskiden kendi kopyasını istiyordu + (`listPresets`); admin.js'in tuttuğu `SVPresets` listesi değişiklik + yayınıyla zaten güncel. İkinci kopya 10.347 presette yüzlerce MB + demekti (ölçülen: panel 631 MB). Ortak liste hazır değilse (açılışın + ilk anı, testler) eski yol. */ + function fromStore() { + const S = window.SVPresets; + if (!S || typeof S.ready !== 'function' || !S.ready() || typeof S.user !== 'function') return false; + presets = builtins().concat(S.user().filter((p) => p && p.kind === 'milkdrop')); + loaded = true; + return true; + } + + // Kaydedilen/silinen hemen ortak listeye: yayın zaten geliyor, ikisi aynı sonucu veriyor + function adoptDelta(delta) { + const S = window.SVPresets; + if (S && typeof S.applyDelta === 'function' && typeof S.ready === 'function' && S.ready()) S.applyDelta(delta); + } + function refresh(cb) { + if (fromStore()) { + if (cb) cb(); + else if (staleRender) { staleRender = false; P().rerender(); } + return; + } if (!window.api || !window.api.listPresets || loading) return; loading = true; const done = () => { @@ -148,6 +172,7 @@ } const res = await window.api.savePresets(read.presets); const saved = (res && res.saved) || []; + adoptDelta({ upsert: saved }); if (L) L.adopt(library(P().cfg()), read.library, saved); refresh(() => { P().apply(); @@ -601,10 +626,13 @@ const rerender = () => P().apply(); const nodes = []; - if (!loaded && !loading) { - refresh(() => P().rerender()); - } else if (!loaded) { - staleRender = true; + // Ortak liste değişiklik yayınıyla güncel (#574): her çizimde ondan + if (!fromStore()) { + if (!loaded && !loading) { + refresh(() => P().rerender()); + } else if (!loaded) { + staleRender = true; + } } // Durum @@ -1004,7 +1032,8 @@ }); } if (items.length && window.api.savePresets) { - await window.api.savePresets(items); + const res = await window.api.savePresets(items); + adoptDelta({ upsert: (res && res.saved) || [] }); } busy = ''; refresh(() => { @@ -1152,6 +1181,7 @@ onclick: async () => { if (!(await P().confirm('"' + (p.name || p.id) + '" silinsin mi?'))) return; if (window.api.deletePreset) await window.api.deletePreset(p.id); + adoptDelta({ remove: [p.id] }); if (md.presetId === p.id) load(cfg, null); // Puanı, favorisi ve etiketleri de gidiyor (#576): kimlik bir daha gelmiyor if (LBn) Object.assign(library(cfg), LBn.forget(library(cfg), p.id)); diff --git a/src/admin/studio.js b/src/admin/studio.js index 3d880d07cee8ffa794b44ebe3ee4b7c4dbe8e81e..ad05ae9742e20e5aafbbb07ff27366dcf075bdf5 100644 GIT binary patch delta 529 zcmZvWKWh|07{)op<}7UNB>13m*$r{8@K4~DtCA)N8nQ)@VDWnMUh=Ygv&+ov!CVOW z0(J*lSmY}hwzUz?FW`4Dm4%-`oV_Dpf>R8`Jiqt%ykCEHe*Ery`OrJ-Z{QVq#Yzg+ zeJh1%);Q)#xJ%VEwTEphb7v=?$3v!;J_D6L=dO|kf zGcshtW0qi}AK-DVM@~u&Qf1{L#f767Criz!)Yc)x5W86vr^`PXBg&4^>%sYo%Q3Qq zs;cli?I4E3A=a;y>(?m5r2Kz?ELkQ|9A8tdHkp5WeZTqGdwXYXwcZKp%Cd0-9Gavu zY}viTX*_@P^igwnbFX{v1~&h+wRQ`q&Hl^F+fQy}IJ7k7eYXFI(&pLVbLZg|X?Rjj XIg}|wW0#0(%hK-r4?SpprU!$+@8P0NMZ2@BzH+0kDiFv-~Gx1(Q=|9J8J+PztjpINbxYD?F9~v*bN)I$4_#Y5)KL diff --git a/src/main/main.js b/src/main/main.js index 498bd96..52f07ef 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -1632,27 +1632,42 @@ function notifyAll(channel, payload) { sendToVisualizers(channel, payload); } -function broadcastPresets() { - const list = presetsStore.list(); - notifyAll('presets', list); - streamServer.broadcast({ type: 'presets', presets: list }); - return list; +/* PRESET YAYINI (#574). Önceden her kayıt bütün listeyi — kaynaklarıyla — + panele, her pencereye, Spout/Syphon'a ve web istemcilerine yeniden + gönderiyordu; 10.347 presette her değişim 116 MB ve ~2,4 sn ana süreç + kilidi. Şimdi yalnız değişenler gidiyor (`presets-delta`); bütün liste + yalnız sayfa açılırken isteniyor (`presets:list`). Web istemcilerine + MilkDrop kaynakları hiç gitmiyor (stream-server.js `publicPresets`). */ +function broadcastPresetDelta(upsert, remove) { + const delta = { upsert: upsert || [], remove: remove || [] }; + if (!delta.upsert.length && !delta.remove.length) return; + notifyAll('presets-delta', delta); + streamServer.broadcast({ type: 'presets-delta', delta }); } -ipcMain.handle('presets:list', () => presetsStore.list()); +// Açılışta arka planda okunmuş olabilir; değilse okuma burada bitiyor +ipcMain.handle('presets:list', async () => { + await presetsStore.warm(); + return presetsStore.list(); +}); ipcMain.handle('presets:save', (e, preset) => { const r = presetsStore.save(preset); - if (r.ok) broadcastPresets(); + if (r.ok) broadcastPresetDelta([r.preset], []); return r; }); ipcMain.handle('presets:delete', (e, id) => { const r = presetsStore.remove(id); - if (r.ok) broadcastPresets(); + if (r.ok) broadcastPresetDelta([], [String(id)]); return r; }); -ipcMain.handle('presets:save-many', (e, list) => { - const saved = presetsStore.saveMany(list); - if (saved.length) broadcastPresets(); +/* Toplu kayıt parça parça (presets-store.js `saveManyAsync`); kaydeden + sayfaya ilerleme gidiyor, yayın bir kez ve sonunda. */ +ipcMain.handle('presets:save-many', async (e, list) => { + const sender = e.sender; + const saved = await presetsStore.saveManyAsync(list, (done, total) => { + if (sender && !sender.isDestroyed()) sender.send('presets-progress', { done, total }); + }); + if (saved.length) broadcastPresetDelta(saved, []); return { ok: true, saved }; }); /* MilkDrop preset paketleri yüzlerce .milk dosyasından oluşur; tek tek @@ -1936,6 +1951,12 @@ function syncStreamServer() { mdTextureFile: (name) => textureFile(name), /* MilkDrop sprite resmi (#577): kimlikle; yol ana süreçte çözülüyor. */ mdSpriteFile: (key) => spriteImageFile(key), + /* MilkDrop preset kaynağı (#574): listede kaynaksız gidiyor, sayfa + çizeceği presetin kaynağını kimliğiyle istiyor. */ + mdPresetSource: (id) => { + const p = presetsStore.get(id); + return p && p.kind === 'milkdrop' && typeof p.source === 'string' ? p.source : null; + }, logoFile: (id) => logoLibrary.fileInfo(logoLibDir(), id), onCommand: (msg, client) => applyRemoteCommand(msg, client), onClientsChanged: (list) => { @@ -2712,6 +2733,10 @@ app.whenReady().then(async () => { dynamicLighting.setConfig(currentConfig.lighting).catch(() => {}); } syncNowPlaying(); + /* Preset deposu arka planda okunmaya başlıyor (#574): pencereler açılıp + listeyi isteyene kadar binlerce dosyanın okuması büyük ölçüde bitmiş + oluyor ve ana süreç o arada bekletilmiyor. */ + presetsStore.warm().catch(() => {}); // Medya katmanının video dosyalarını okuduğu protokol. Yalnızca // yapılandırmada SEÇİLİ olan dosyayı açar; sayfaya genel dosya sistemi diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index 69a58f5..d965248 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -107,6 +107,9 @@ contextBridge.exposeInMainWorld('api', { openPresetsFolder: () => ipcRenderer.invoke('presets:open-folder'), importShaderText: () => ipcRenderer.invoke('presets:import-text'), onPresets: (cb) => ipcRenderer.on('presets', (e, list) => cb(list)), + // Değişiklik yayını ve toplu kaydın ilerlemesi (#574) + onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), + onPresetsProgress: (cb) => ipcRenderer.on('presets-progress', (e, p) => cb(p)), // Yayın çıkışı (OBS tarayıcı kaynağı + mobil kumanda) streamStatus: () => ipcRenderer.invoke('stream:status'), diff --git a/src/main/preload-visualizer.js b/src/main/preload-visualizer.js index 5906a4c..f311c31 100644 --- a/src/main/preload-visualizer.js +++ b/src/main/preload-visualizer.js @@ -26,6 +26,8 @@ contextBridge.exposeInMainWorld('api', { // Studio presetleri ana süreçte tutulur (settings.json şişmesin diye) getPresets: () => ipcRenderer.invoke('presets:list'), onPresets: (cb) => ipcRenderer.on('presets', (e, list) => cb(list)), + // Değişiklik yayını (#574): bütün liste yerine yalnız değişenler + onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), /* MilkDrop doku paketi: preset kendi görselini ada göre istiyor (`sampler_worms` -> `worms.jpg`). Yalnızca yapılandırmada seçili klasörün içi okunuyor; kapsam denetimi ana süreçte. */ diff --git a/src/main/presets-store.js b/src/main/presets-store.js index 3c8013a..cbd7ff1 100644 --- a/src/main/presets-store.js +++ b/src/main/presets-store.js @@ -7,16 +7,27 @@ koymak dosyayı gereksiz büyütür ve her sürüklemede kilobaytlarca metni yeniden diske yazardı. - Dosya adı preset kimliğinden türetilir ve dizin dışına çıkamaz. */ + Dosya adı preset kimliğinden türetilir ve dizin dışına çıkamaz. + + ÖNBELLEK (#574). Önceden her `list()` klasördeki bütün dosyaları okuyup + ayrıştırıyordu ve her kayıt `list()` çağırıyordu. Ölçüldü (10.347 MilkDrop + preseti, 116 MB): `list()` 3,8 sn, tek bir kaydın turu ana süreci ~2,4 sn + kilitliyordu. Şimdi dosyalar bir kez okunuyor — açılışta arka planda + (`warm`) — ve kayıt ile silme önbelleği yerinde güncelliyor. `list()` her + çağrıda klasörün yalnız ADLARINA bakıyor (10 bin adda milisaniyeler): elle + eklenen ya da silinen dosya yine görünüyor. Elle DEĞİŞTİRİLEN bir dosya + uygulama yeniden açılınca görünüyor. */ const fs = require('fs'); const path = require('path'); const { app } = require('electron'); const MAX_PRESET_BYTES = 512 * 1024; // tek preset üst sınırı (shader metni) +const WRITE_BATCH = 50; // toplu kayıtta aynı anda yazılan dosya +let root = null; // test kancası: `setDir` function dir() { - const d = path.join(app.getPath('userData'), 'presets'); + const d = root || path.join(app.getPath('userData'), 'presets'); try { fs.mkdirSync(d, { recursive: true }); } catch { /* zaten var */ } return d; } @@ -33,27 +44,108 @@ function fileFor(id) { return n ? path.join(dir(), n + '.json') : null; } -function list() { - let names = []; - try { names = fs.readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return []; } - const out = []; - for (const n of names) { - try { - const raw = fs.readFileSync(path.join(dir(), n), 'utf-8'); - const p = JSON.parse(raw); - if (p && p.id) out.push(p); - } catch { /* bozuk dosyayı atla */ } +/* Sıra: son değişen önce, eşitlikte kimlik. Sayfalardaki liste + (shared/presets.js `compare`) aynı kuralla sıralanıyor; değişiklik yayını + uygulanınca iki taraf aynı sırada kalıyor. */ +function compare(a, b) { + const d = (Number(b && b.updatedAt) || 0) - (Number(a && a.updatedAt) || 0); + if (d) return d; + const x = String(a && a.id); + const y = String(b && b.id); + return x < y ? -1 : x > y ? 1 : 0; +} + +let byFile = null; // dosya adı → preset +let sorted = null; // sıralı liste; değişince null +let warming = null; + +function names() { + try { return fs.readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return []; } +} + +function parse(raw) { + try { + const p = JSON.parse(raw); + return p && typeof p === 'object' && p.id ? p : null; + } catch { return null; } // bozuk dosya atlanıyor +} + +function readOne(name) { + try { return parse(fs.readFileSync(path.join(dir(), name), 'utf-8')); } catch { return null; } +} + +function ensure() { + if (byFile) return; + const map = new Map(); + for (const n of names()) { + const p = readOne(n); + if (p) map.set(n, p); } - out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); - return out; + byFile = map; + sorted = null; } -function save(preset) { +/* Açılışta arka planda okuma: ilk `presets:list` isteği binlerce dosyayı + eşzamanlı okuyup ana süreci tutmasın. Bu arada eşzamanlı bir `ensure()` + (ör. açılışta bir kayıt) yüklemeyi bitirdiyse onunki kalıyor. */ +function warm() { + if (byFile) return Promise.resolve(); + if (warming) return warming; + warming = (async () => { + const map = new Map(); + const ns = names(); + const d = dir(); + for (let i = 0; i < ns.length; i += 64) { + await Promise.all(ns.slice(i, i + 64).map(async (n) => { + try { + const p = parse(await fs.promises.readFile(path.join(d, n), 'utf-8')); + if (p) map.set(n, p); + } catch { /* okunamayan dosya atlanıyor */ } + })); + } + if (!byFile) { byFile = map; sorted = null; } + })(); + return warming; +} + +// Klasörle eşitle: yeni adlar okunuyor, kaybolanlar düşüyor +function rescan() { + const now = names(); + const seen = new Set(now); + let changed = false; + for (const n of now) { + if (byFile.has(n)) continue; + const p = readOne(n); + if (p) { byFile.set(n, p); changed = true; } + } + for (const n of Array.from(byFile.keys())) { + if (!seen.has(n)) { byFile.delete(n); changed = true; } + } + if (changed) sorted = null; +} + +function list() { + ensure(); + rescan(); + if (!sorted) sorted = Array.from(byFile.values()).sort(compare); + return sorted.slice(); +} + +function get(id) { + const n = safeName(id); + if (!n) return null; + ensure(); + return byFile.get(n + '.json') || null; +} + +/* Kaydın hazırlığı: doğrulama, kimlik ve zaman damgası. `stamp` verilirse + o (toplu kayıtta paketin kendi sırası korunsun diye), yoksa şimdi. */ +function prepare(preset, stamp) { if (!preset || typeof preset !== 'object') return { ok: false, error: 'INVALID' }; const p = Object.assign({}, preset); p.builtin = false; if (!p.id) p.id = 'usr_' + Date.now().toString(36) + '_' + Math.floor(Math.random() * 65536).toString(36); - p.updatedAt = Date.now(); + p.updatedAt = typeof stamp === 'number' ? stamp : Date.now(); if (!p.createdAt) p.createdAt = p.updatedAt; const file = fileFor(p.id); if (!file) return { ok: false, error: 'BAD_ID' }; @@ -61,9 +153,22 @@ function save(preset) { if (Buffer.byteLength(json, 'utf-8') > MAX_PRESET_BYTES) { return { ok: false, error: 'TOO_LARGE' }; } + return { ok: true, p, file, json }; +} + +function remember(file, p) { + if (!byFile) return; // henüz okunmadı: dosya ilk okumada gelecek + byFile.set(path.basename(file), p); + sorted = null; +} + +function save(preset) { + const r = prepare(preset); + if (!r.ok) return r; try { - fs.writeFileSync(file, json, 'utf-8'); - return { ok: true, preset: p }; + fs.writeFileSync(r.file, r.json, 'utf-8'); + remember(r.file, r.p); + return { ok: true, preset: r.p }; } catch (e) { return { ok: false, error: e.message }; } @@ -74,13 +179,14 @@ function remove(id) { if (!file) return { ok: false, error: 'BAD_ID' }; try { fs.unlinkSync(file); + if (byFile) { byFile.delete(path.basename(file)); sorted = null; } return { ok: true }; } catch (e) { return { ok: false, error: e.message }; } } -// Birden çok preseti tek seferde ekler (paket içe aktarımı) +// Birden çok preseti tek seferde ekler (eşzamanlı; küçük listeler için) function saveMany(presets) { const saved = []; for (const p of presets || []) { @@ -90,4 +196,39 @@ function saveMany(presets) { return saved; } -module.exports = { list, save, saveMany, remove, dir }; +/* BÜYÜK İÇE AKTARIM (#574). Dosyalar parça parça ve aralarda beklenerek + yazılıyor; ana süreç o arada başka işlere (ses, ışık, IPC) dönebiliyor. + Ölçüldü: 10.347 preset eşzamanlı yazımla ana süreci 7,3 sn tutuyordu. + Sıra paketin kendi sırası: ilk preset en yeni damgayı alıyor ve listede + önce geliyor. `onProgress(bitti, toplam)` her parçadan sonra. */ +async function saveManyAsync(presets, onProgress) { + const all = Array.isArray(presets) ? presets : []; + const saved = []; + const base = Date.now(); + for (let i = 0; i < all.length; i += WRITE_BATCH) { + const part = all.slice(i, i + WRITE_BATCH); + const rs = await Promise.all(part.map(async (preset, k) => { + const r = prepare(preset, base - (i + k)); + if (!r.ok) return null; + try { + await fs.promises.writeFile(r.file, r.json, 'utf-8'); + remember(r.file, r.p); + return r.p; + } catch { return null; } + })); + for (const p of rs) if (p) saved.push(p); + if (typeof onProgress === 'function') onProgress(Math.min(all.length, i + WRITE_BATCH), all.length); + await new Promise((res) => setImmediate(res)); + } + return saved; +} + +// Test kancası: depoyu başka bir klasöre yönelt ve önbelleği boşalt +function setDir(d) { + root = d || null; + byFile = null; + sorted = null; + warming = null; +} + +module.exports = { list, get, save, saveMany, saveManyAsync, remove, dir, warm, compare, setDir }; diff --git a/src/main/stream-server.js b/src/main/stream-server.js index 8ba248a..39c5183 100644 --- a/src/main/stream-server.js +++ b/src/main/stream-server.js @@ -187,6 +187,11 @@ function clientInfo() { function broadcast(obj, kind) { if (obj && obj.type === 'config') obj = Object.assign({}, obj, { config: publicConfig(obj.config) }); + else if (obj && obj.type === 'presets') obj = Object.assign({}, obj, { presets: publicPresets(obj.presets) }); + else if (obj && obj.type === 'presets-delta') { + const d = obj.delta || {}; + obj = Object.assign({}, obj, { delta: { upsert: publicPresets(d.upsert), remove: Array.isArray(d.remove) ? d.remove : [] } }); + } for (const c of clients) { if (kind && c.kind !== kind) continue; sendJson(c, obj); @@ -200,6 +205,23 @@ function broadcast(obj, kind) { ve klasör değişti mi (değişince sayfa listeyi yeniden istiyor). Kumanda sayfası yapılandırmayı geri yazmıyor (yalnız izinli yollar ve sahne kimliği), yani özet gerçek ayara hiç karışmıyor. */ +/* WEB İSTEMCİLERİNE GİDEN PRESET LİSTESİ (#574). MilkDrop presetleri + KAYNAKSIZ gidiyor (`lazy`): 10.347 presetlik bir kütüphane 116 MB ediyordu + ve her bağlantıda ve her değişimde her istemciye — telefondaki kumandaya + da — gidiyordu. Kaynak, preset gerçekten çizilecekken kimliğiyle + isteniyor (`/milkdrop/preset`, jetonun arkasında). Studio presetleri + küçük ve sayfa onları hemen çiziyor; olduğu gibi gidiyor. */ +function publicPreset(p) { + if (!p || typeof p !== 'object' || p.kind !== 'milkdrop' || typeof p.source !== 'string') return p; + const c = Object.assign({}, p, { lazy: true }); + delete c.source; + return c; +} + +function publicPresets(list) { + return (Array.isArray(list) ? list : []).map(publicPreset); +} + function publicConfig(cfg) { const md = cfg && cfg.milkdrop; if (!md || typeof md.textureDir !== 'string' || !md.textureDir) return cfg; @@ -394,6 +416,7 @@ function handleRequest(req, res) { if (p === '/milkdrop/textures') { serveTextureNames(res); return; } if (p === '/milkdrop/texture') { serveTexture(res, url.searchParams.get('name') || ''); return; } if (p === '/milkdrop/sprite') { serveSprite(res, url.searchParams.get('key') || ''); return; } + if (p === '/milkdrop/preset') { servePresetSource(res, url.searchParams.get('id') || ''); return; } if (p.startsWith('/app/')) { serveStatic(res, p); return; } res.writeHead(404).end('not found'); } @@ -459,6 +482,22 @@ function serveSprite(res, key) { fs.createReadStream(t.file).on('error', () => res.destroy()).pipe(res); } +/* MILKDROP PRESET KAYNAĞI (#574). Listede kaynaksız giden presetin kaynağı, + kimliğiyle. Kimlik ana süreçteki depoda aranıyor; yalnız MilkDrop + presetleri. Jetonun arkasında. */ +function servePresetSource(res, id) { + const src = typeof hooks.mdPresetSource === 'function' ? hooks.mdPresetSource(id) : null; + if (typeof src !== 'string') { res.writeHead(404).end('not found'); return; } + const body = JSON.stringify({ id, source: src }); + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(body); +} + /* Sayfayı servis ederken uygulamanın dilini enjekte eder ve alt kaynaklar için güvenli oturum çerezini tanımlar. */ function sendPage(res, file, cookieName, tokenVal) { @@ -539,7 +578,7 @@ function handleUpgrade(req, socket) { version: typeof hooks.getVersion === 'function' ? String(hooks.getVersion() || '') : '', }); sendJson(client, { type: 'config', config: publicConfig(hooks.getConfig()) }); - sendJson(client, { type: 'presets', presets: hooks.getPresets() }); + sendJson(client, { type: 'presets', presets: publicPresets(hooks.getPresets()) }); const np = (typeof hooks.getNowPlaying === 'function' ? hooks.getNowPlaying() : null) || lastNowPlaying; if (np && np.has) { sendJson(client, { type: 'now-playing', state: np }); @@ -733,6 +772,7 @@ module.exports = { newToken, lanAddress, publicConfig, + publicPresets, clientCount: () => clients.size, // Ses karesi tüketen istemci sayısı (mobil kumanda sayılmaz) overlayCount: () => { diff --git a/src/shared/presets.js b/src/shared/presets.js index d3db313..1002d72 100644 --- a/src/shared/presets.js +++ b/src/shared/presets.js @@ -242,8 +242,39 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord){ } } + let userSet = false; function setUser(list) { userPresets = (Array.isArray(list) ? list : []).map(normalize); + userSet = true; + } + + /* LİSTE SIRASI (#574): son değişen önce, eşitlikte kimlik. Ana süreçteki + depo (main/presets-store.js `compare`) aynı kuralla sıralıyor; değişiklik + yayını uygulanınca iki taraf aynı sırada kalıyor — sıralı otomatik geçiş + bu sırayla ilerliyor. Eşitlik bozulmasaydı aynı milisaniyede kaydedilen + presetlerin sırası makineden makineye değişirdi. */ + function compare(a, b) { + const d = (Number(b && b.updatedAt) || 0) - (Number(a && a.updatedAt) || 0); + if (d) return d; + const x = String(a && a.id); + const y = String(b && b.id); + return x < y ? -1 : x > y ? 1 : 0; + } + + /* DEĞİŞİKLİK YAYINI (#574). Bütün liste yerine yalnız değişenler geliyor: + 10.347 presetlik bir kütüphanede tek bir silme her pencereye 116 MB + gönderiyordu. `upsert` eklenen ya da değişen presetler, `remove` + silinenlerin kimlikleri. Aynı yayın iki kez uygulanırsa sonuç değişmiyor + (kaydeden sayfa sonucu kendisi de uygulayabiliyor). */ + function applyDelta(delta) { + const d = delta || {}; + const drop = new Set((Array.isArray(d.remove) ? d.remove : []).map(String)); + const up = (Array.isArray(d.upsert) ? d.upsert : []) + .filter((p) => p && typeof p === 'object' && p.id) + .map(normalize); + for (const p of up) drop.add(String(p.id)); + const kept = drop.size ? userPresets.filter((p) => !drop.has(String(p.id))) : userPresets; + userPresets = kept.concat(up).sort(compare); } function all() { return BUILTIN_LIST.concat(userPresets); @@ -525,5 +556,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord){ makePack, readImported, user: () => userPresets.slice(), + // Değişiklik yayını ve ortak sıra (#574) + compare, + applyDelta, + ready: () => userSet, }; })(); diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index 9097bfc..3cb29ee 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -1790,7 +1790,8 @@ void main(){ önizleme harmanlasaydı ikisi bir saniye boyunca farklı görünürdü. */ if (f) { this.autoPick = { - id: f.id, name: f.name || '', source: f.source || '', cut: !!F.cut, + // Web çıkışında kaynak listede yok (#574): `_ensurePreset` istiyor + id: f.id, name: f.name || '', source: f.source || '', lazy: !!f.lazy && !f.source, cut: !!F.cut, blend: typeof F.blend === 'number' ? F.blend : null, // Liderin tohumu: aynı rand_preset ve aynı geçiş deseni (#585) seed: Number.isInteger(F.seed) ? F.seed : null, @@ -1836,7 +1837,7 @@ void main(){ } if (p) { this.autoPick = { - id: p.id, name: p.name || '', source: p.source || '', cut: this.cycle.cut, + id: p.id, name: p.name || '', source: p.source || '', lazy: !!p.lazy && !p.source, cut: this.cycle.cut, blend: this.cycle.blend, /* Bu seçimin tohumu (#585). Seçimle birlikte izleyenlere gidiyor; döngünün kendi rastgele akışından çekilmiyor, sıra seçimi @@ -1933,6 +1934,17 @@ void main(){ if (this.cycle) this.cycle.reset(); } const a = this.autoPick; + /* KAYNAĞI YOLDA OLAN SEÇİM (#574, web çıkışı): liste MilkDrop + presetlerini kaynaksız taşıyor; kaynak kimlikle isteniyor ve gelene + kadar ekrandaki preset (ve süren geçiş) çizilmeye devam ediyor. */ + if (a && a.lazy) { + const s = this._lazySource(a.id); + if (s === null) return; + // Alınamadı (silinmiş, sunucu yok): varsayılana düşmek yerine seçim bırakılıyor + if (!s) { this.autoPick = null; return; } + a.source = s; + a.lazy = false; + } const key = a ? (a.id + '|' + a.source.length) : man; if (key === this.presetKey && this.preset) { /* Derlenirken seçim geri alındıysa yarım iş atılıyor (#573). Önceden @@ -2050,7 +2062,38 @@ void main(){ değişimin derlemesine dokunulmuyor; ekrandaki presete de. Değişim gelince `_ensurePreset` bu işi hazır bulup kullanıyor, başka bir preset gelirse iş atılıyor. */ + /* KAYNAK GEREKTİĞİNDE (#574). Web çıkışında MilkDrop presetleri listede + kaynaksız (yayın sunucusu `publicPresets`): 10.347 presetlik bir + kütüphane her istemciye her değişimde 116 MB gidiyordu. Kaynak + kimliğiyle isteniyor ve gelince listedeki kayda yazılıyor — bir daha + istenmiyor. Dönüş: kaynak; istek yoldaysa null; alınamadıysa ''. */ + _lazySource(id) { + const S = typeof window !== 'undefined' && window.SVPresets; + const p = S && typeof S.get === 'function' ? S.get(id) : null; + if (p && typeof p.source === 'string' && p.source) return p.source; + const api = typeof window !== 'undefined' && window.api; + if (!api || typeof api.milkdropPresetSource !== 'function') return ''; + const box = this._srcReq || (this._srcReq = new Map()); + const r = box.get(id); + if (r) return r.done ? r.source : null; + const rec = { done: false, source: '' }; + box.set(id, rec); + Promise.resolve(api.milkdropPresetSource(id)).then((s) => { + rec.done = true; + rec.source = typeof s === 'string' ? s : ''; + if (p && rec.source) p.source = rec.source; + }, () => { rec.done = true; }); + return null; + } + _prefetch(p) { + /* Web çıkışında kaynak listede yok (#574): önceden derlemenin ilk işi + kaynağı istemek; derleme kaynak gelince bir sonraki fırsatta. */ + if (p.lazy && !p.source) { + const s = this._lazySource(p.id); + if (s === null) return; + p = Object.assign({}, p, { source: s }); + } const key = p.id + '|' + (p.source || '').length; if (key === this.presetKey) return; const P = this._pending; diff --git a/src/visualizer/visualizer.js b/src/visualizer/visualizer.js index fa85adf..2c2046c 100644 --- a/src/visualizer/visualizer.js +++ b/src/visualizer/visualizer.js @@ -526,6 +526,24 @@ stack.dispose(); applyScene(); }); + /* DEĞİŞİKLİK YAYINI (#574): yalnız değişenler geliyor. Sahne yalnız bir + Studio preseti değişince yeniden kuruluyor; MilkDrop presetlerinin + listesi değişince (içe aktarım, silme) motor baştan başlamıyor — + çizdiği kaynak ayardan ya da seçimden geliyor, listeden değil. + Önceden her içe aktarım ekrandaki MilkDrop'u sıfırlıyordu. Silinen + presetin türü, liste güncellenmeden ÖNCE okunuyor. */ + if (window.api.onPresetsDelta) { + window.api.onPresetsDelta((d) => { + const S = window.SVPresets; + const studio = (d && Array.isArray(d.upsert) ? d.upsert : []).some((p) => p && p.kind !== 'milkdrop') || + (d && Array.isArray(d.remove) ? d.remove : []).some((id) => { const p = S.get(id); return !!p && p.kind !== 'milkdrop'; }); + S.applyDelta(d); + if (studio) { + stack.dispose(); + applyScene(); + } + }); + } const saved = await window.api.requestConfig(); if (saved) cfg = window.SV.deepMerge(window.SV.defaultConfig(), saved); diff --git a/src/web/web-shim.js b/src/web/web-shim.js index b767516..0eb7995 100644 --- a/src/web/web-shim.js +++ b/src/web/web-shim.js @@ -18,11 +18,18 @@ const fpsOverride = parseInt(params.get('fps') || '', 10); const scaleOverride = parseFloat(params.get('scale') || ''); - const handlers = { config: [], audio: [], presets: [], status: [], nowPlaying: [], showClock: [], mdFollow: [], mdSprite: [] }; + /* `presets` bütün liste geldiğinde (bağlanınca), `presetsDelta` yalnız + değişenler geldiğinde (#574), `presetsList` her ikisinde güncel listenin + tamamıyla (kumanda sayfası listeyi kendisi tutmuyor). */ + const handlers = { + config: [], audio: [], presets: [], presetsDelta: [], presetsList: [], status: [], nowPlaying: [], + showClock: [], mdFollow: [], mdSprite: [], + }; let ws = null; let retry = 0; let firstConfig = null; let firstPresets = null; + let presetList = []; let resolveConfig; let resolvePresets; const configReady = new Promise((r) => { resolveConfig = r; }); @@ -142,8 +149,19 @@ handlers.config.forEach((h) => h(c)); } else if (msg.type === 'presets') { const list = msg.presets || []; + presetList = list.slice(); if (!firstPresets) { firstPresets = list; resolvePresets(list); } handlers.presets.forEach((h) => h(list)); + handlers.presetsList.forEach((h) => h(presetList)); + } else if (msg.type === 'presets-delta') { + /* Değişiklik yayını (#574): köprü güncel listeyi kendisi de tutuyor, + kumanda bütün listeyi alsın; görselleştirici değişiklikle yetiniyor. */ + const d = msg.delta || {}; + const up = Array.isArray(d.upsert) ? d.upsert.filter((p) => p && p.id) : []; + const drop = new Set((Array.isArray(d.remove) ? d.remove : []).map(String).concat(up.map((p) => String(p.id)))); + presetList = presetList.filter((p) => !drop.has(String(p && p.id))).concat(up); + handlers.presetsDelta.forEach((h) => h(d)); + handlers.presetsList.forEach((h) => h(presetList)); } else if (msg.type === 'now-playing') { if (window.SVNowLive) window.SVNowLive.state = msg.state; else window.SVNowLive = { state: msg.state }; @@ -224,6 +242,7 @@ onNativeAudio: (cb) => handlers.audio.push(cb), getPresets: () => presetsReady, onPresets: (cb) => handlers.presets.push(cb), + onPresetsDelta: (cb) => handlers.presetsDelta.push(cb), onNowPlaying: (cb) => handlers.nowPlaying.push(cb), onShowClock: (cb) => handlers.showClock.push(cb), onMdFollow: (cb) => handlers.mdFollow.push(cb), @@ -249,13 +268,21 @@ url: '/milkdrop/sprite?key=' + encodeURIComponent(String(key || '')) + (token ? '&token=' + encodeURIComponent(token) : ''), }), + /* MilkDrop preset kaynağı (#574): liste kaynaksız geliyor; motor çizeceği + presetin kaynağını kimliğiyle istiyor. Dönüş: kaynak ya da null. */ + milkdropPresetSource: (id) => fetch('/milkdrop/preset?id=' + encodeURIComponent(String(id || '')) + + (token ? '&token=' + encodeURIComponent(token) : ''), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => (j && typeof j.source === 'string' ? j.source : null)) + .catch(() => null), }; // Uzaktan kumanda sayfasının kullandığı ek yüzey window.SVRemote = { send, onConfig: (cb) => handlers.config.push(cb), - onPresets: (cb) => handlers.presets.push(cb), + // Güncel listenin tamamı: bağlanınca ve her değişiklikte (#574) + onPresets: (cb) => handlers.presetsList.push(cb), onStatus: (cb) => handlers.status.push(cb), onNowPlaying: (cb) => handlers.nowPlaying.push(cb), ready: configReady, diff --git a/tests/presets-scale.test.js b/tests/presets-scale.test.js new file mode 100644 index 0000000..61ba99e --- /dev/null +++ b/tests/presets-scale.test.js @@ -0,0 +1,451 @@ +'use strict'; +/* BÜYÜK KÜTÜPHANE (#574, ölçek). Ölçüldü (10.347 MilkDrop preseti, 116 MB, + * yalıtılmış kopya): `presets:list` 3,8 sn, tek bir kaydın turu ana süreci + * ~2,4 sn kilitliyordu, panel 631 MB tutuyordu ve web istemcileri her + * değişimde 116 MB alıyordu. Değişiklikten sonra: liste 0,3 sn, kayıt turu + * 91 ms, panel 273 MB. + * + * - Ana süreçteki depo dosyaları bir kez okuyor ve önbellekte tutuyor. + * - Kayıt ve silme bütün listeyi değil yalnız değişenleri yayınlıyor. + * - Sayfalar değişikliği aynı sırayla uyguluyor (son değişen önce, + * eşitlikte kimlik). + * - Web istemcilerine MilkDrop kaynakları gitmiyor; motor çizeceği + * presetin kaynağını kimliğiyle istiyor. + * - MilkDrop paneli ikinci bir kopya tutmuyor. + */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const Module = require('module'); +const os = require('os'); +const path = require('path'); +const vm = require('vm'); +require('../src/shared/defaults.js'); +require('../src/shared/presets.js'); +const SVP = global.window.SVPresets; + +const ROOT = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf-8'); +const bare = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 '); + +/* Depo `electron`u istiyor; CI bağımlılık kurmuyor. Yalnız bu yükleme için + sahte bir `app` veriliyor — klasörü test kendisi seçiyor (`setDir`). */ +const origLoad = Module._load; +Module._load = function (request) { + if (request === 'electron') return { app: { getPath: () => os.tmpdir() } }; + return origLoad.apply(this, arguments); +}; +const STORE = require('../src/main/presets-store.js'); +Module._load = origLoad; + +const made = []; +test.after(() => { STORE.setDir(null); for (const d of made) fs.rmSync(d, { recursive: true, force: true }); }); +function tmpStore() { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-store-')); + made.push(d); + STORE.setDir(d); + return d; +} +const md = (id, t, extra) => Object.assign({ id, kind: 'milkdrop', name: 'Y - ' + id, source: '[preset00]\nzoom=1.0' + id.length + '\n', updatedAt: t }, extra || {}); +const write = (d, p) => fs.writeFileSync(path.join(d, p.id + '.json'), JSON.stringify(p)); + +// ------------------------------------------------------------------- sıra + +test('sıra: son değişen önce, eşitlikte kimlik — depo ve sayfalar aynı', () => { + const rnd = crypto.randomBytes(400); + const list = []; + for (let i = 0; i < 200; i++) list.push({ id: 'p' + rnd[i].toString(16) + i, updatedAt: rnd[200 + i] % 5 }); + const a = list.slice().sort(STORE.compare).map((p) => p.id); + const b = list.slice().sort(SVP.compare).map((p) => p.id); + assert.deepStrictEqual(a, b); + assert.deepStrictEqual([{ id: 'b', updatedAt: 1 }, { id: 'a', updatedAt: 1 }, { id: 'c', updatedAt: 2 }].sort(SVP.compare).map((p) => p.id), ['c', 'a', 'b']); +}); + +// ------------------------------------------------------------------ depo + +test('depo: dosyalar bir kez okunuyor; kayıt ve silme önbelleği güncelliyor', () => { + const d = tmpStore(); + write(d, md('md_a', 3)); + write(d, md('md_b', 2)); + assert.deepStrictEqual(STORE.list().map((p) => p.id), ['md_a', 'md_b']); + // İkinci liste ve bir kayıttan sonraki liste HİÇ preset dosyası okumuyor + const origRead = fs.readFileSync; + let reads = 0; + fs.readFileSync = function (f) { if (String(f).endsWith('.json')) reads++; return origRead.apply(this, arguments); }; + try { + STORE.list(); + const r = STORE.save(md('md_c', 0)); + assert.ok(r.ok); + assert.strictEqual(STORE.list()[0].id, 'md_c', 'yeni kayıt en önde'); + assert.ok(STORE.remove('md_a').ok); + assert.deepStrictEqual(STORE.list().map((p) => p.id), ['md_c', 'md_b']); + } finally { + fs.readFileSync = origRead; + } + assert.strictEqual(reads, 0, 'önbellek her listede dosyaları yeniden okumamalı'); + assert.strictEqual(STORE.get('md_b').name, 'Y - md_b'); + assert.strictEqual(STORE.get('../md_b'), STORE.get('md_b'), 'kimlik güvenli ada indirgeniyor'); + assert.strictEqual(STORE.get('yok'), null); +}); + +test('depo: klasöre elle eklenen ya da silinen dosya yine görünüyor', () => { + const d = tmpStore(); + write(d, md('md_a', 2)); + assert.strictEqual(STORE.list().length, 1); + write(d, md('md_elle', 5)); + fs.writeFileSync(path.join(d, 'bozuk.json'), '{ yarım'); + assert.deepStrictEqual(STORE.list().map((p) => p.id), ['md_elle', 'md_a'], 'bozuk dosya atlanıyor'); + fs.unlinkSync(path.join(d, 'md_a.json')); + assert.deepStrictEqual(STORE.list().map((p) => p.id), ['md_elle']); +}); + +test('depo: arka planda okuma; bu arada yapılan kayıt kaybolmuyor', async () => { + const d = tmpStore(); + for (let i = 0; i < 150; i++) write(d, md('md_w' + i, i)); + const w = STORE.warm(); + const r = STORE.save(md('md_arada', 0)); + assert.ok(r.ok); + await w; + const ids = STORE.list().map((p) => p.id); + assert.strictEqual(ids.length, 151); + assert.ok(ids.includes('md_arada')); + assert.strictEqual(ids[0], 'md_arada'); +}); + +test('depo: toplu kayıt parça parça, paketin sırasıyla, ilerlemeyle', async () => { + tmpStore(); + /* Kimlikler bilerek AZALAN sırada: sıra kimlikten değil paketten gelmeli + (eşitlikte kimlik sırası paketi tersine çevirirdi). */ + const items = []; + for (let i = 0; i < 123; i++) items.push({ id: 'md_t' + String(999 - i).padStart(3, '0'), kind: 'milkdrop', name: 'P' + i, source: 'x' }); + items.splice(10, 0, { id: '...', kind: 'milkdrop', name: 'kötü' }, null, { id: 'md_big', source: 'x'.repeat(600 * 1024) }); + const prog = []; + const saved = await STORE.saveManyAsync(items, (done, total) => prog.push([done, total])); + assert.strictEqual(saved.length, 123, 'geçersizler atlanıyor'); + assert.deepStrictEqual(prog.map((p) => p[1]), prog.map(() => 126)); + assert.deepStrictEqual(prog[prog.length - 1], [126, 126]); + assert.ok(prog.length >= 3, 'parça parça'); + // Listede paketin kendi sırası: ilki önde + const order = STORE.list().map((p) => p.id); + assert.deepStrictEqual(order.slice(0, 3), ['md_t999', 'md_t998', 'md_t997']); + assert.strictEqual(order[order.length - 1], 'md_t877'); +}); + +// ------------------------------------------------------- değişiklik yayını + +test('sayfa: değişiklik ekliyor, değiştiriyor, siliyor; iki kez uygulamak aynı', () => { + SVP.setUser([md('a', 3), md('b', 2), md('c', 1)]); + assert.ok(SVP.ready()); + const d = { upsert: [md('b', 9, { name: 'yeni b' }), md('e', 0)], remove: ['c'] }; + SVP.applyDelta(d); + const ids = () => SVP.user().map((p) => p.id); + assert.deepStrictEqual(ids(), ['b', 'a', 'e']); + assert.strictEqual(SVP.get('b').name, 'yeni b'); + SVP.applyDelta(d); + assert.deepStrictEqual(ids(), ['b', 'a', 'e'], 'kaydeden sayfa sonucu kendisi de uyguluyor'); + SVP.applyDelta({ upsert: [null, { name: 'kimliksiz' }], remove: ['yok'] }); + assert.deepStrictEqual(ids(), ['b', 'a', 'e']); + SVP.setUser([]); +}); + +// ---------------------------------------------------------- ana süreç + +const MAIN = bare(read('src/main/main.js')); + +test('ana süreç: kayıt ve silme yalnız değişeni yayınlıyor; bütün liste yayını yok', () => { + assert.doesNotMatch(MAIN, /notifyAll\('presets',/, 'bütün liste yayını kalmamalı'); + assert.doesNotMatch(MAIN, /broadcastPresets\(/); + assert.match(MAIN, /ipcMain\.handle\('presets:save', \(e, preset\) => \{\s*const r = presetsStore\.save\(preset\);\s*if \(r\.ok\) broadcastPresetDelta\(\[r\.preset\], \[\]\);/); + assert.match(MAIN, /ipcMain\.handle\('presets:delete', \(e, id\) => \{\s*const r = presetsStore\.remove\(id\);\s*if \(r\.ok\) broadcastPresetDelta\(\[\], \[String\(id\)\]\);/); + const many = /ipcMain\.handle\('presets:save-many', async \(e, list\) => \{[\s\S]*?\n\}\);/.exec(MAIN); + assert.ok(many, 'toplu kayıt eşzamansız olmalı'); + assert.match(many[0], /presetsStore\.saveManyAsync\(list, /); + assert.match(many[0], /sender\.send\('presets-progress', \{ done, total \}\)/); + assert.match(many[0], /if \(saved\.length\) broadcastPresetDelta\(saved, \[\]\);/); + const fn = /function broadcastPresetDelta\(upsert, remove\) \{[\s\S]*?\n\}/.exec(MAIN)[0]; + assert.match(fn, /notifyAll\('presets-delta', delta\);/); + assert.match(fn, /streamServer\.broadcast\(\{ type: 'presets-delta', delta \}\);/); +}); + +test('ana süreç: depo açılışta arka planda okunuyor; liste isteği onu bekliyor', () => { + assert.match(MAIN, /ipcMain\.handle\('presets:list', async \(\) => \{\s*await presetsStore\.warm\(\);\s*return presetsStore\.list\(\);/); + assert.match(MAIN, /syncNowPlaying\(\);\s*presetsStore\.warm\(\)\.catch\(\(\) => \{\}\);/); + assert.match(MAIN, /mdPresetSource: \(id\) => \{\s*const p = presetsStore\.get\(id\);\s*return p && p\.kind === 'milkdrop' && typeof p\.source === 'string' \? p\.source : null;/); +}); + +test('köprüler: pencere ve panel değişikliği ve ilerlemeyi dinliyor', () => { + assert.match(read('src/main/preload-visualizer.js'), /onPresetsDelta: \(cb\) => ipcRenderer\.on\('presets-delta', \(e, d\) => cb\(d\)\),/); + const A = read('src/main/preload-admin.js'); + assert.match(A, /onPresetsDelta: \(cb\) => ipcRenderer\.on\('presets-delta', \(e, d\) => cb\(d\)\),/); + assert.match(A, /onPresetsProgress: \(cb\) => ipcRenderer\.on\('presets-progress', \(e, p\) => cb\(p\)\),/); + assert.match(bare(read('src/admin/admin.js')), /window\.api\.onPresetsDelta\(\(d\) => \{\s*window\.SVPresets\.applyDelta\(d\);\s*render\(\);/); +}); + +// ------------------------------------------------------------ görselleştirici + +test('görselleştirici: sahne yalnız bir Studio preseti değişince yeniden kuruluyor', () => { + const V = read('src/visualizer/visualizer.js'); + const m = /window\.api\.onPresetsDelta\((\(d\) => \{[\s\S]*?\n {6}\})\);/.exec(V); + assert.ok(m, 'değişiklik dinleyicisi bulunamadı'); + const run = (list, delta) => { + const calls = []; + SVP.setUser(list); + const h = new Function('window', 'stack', 'applyScene', 'return ' + m[1])( + { SVPresets: SVP }, { dispose: () => calls.push('dispose') }, () => calls.push('apply')); + h(delta); + return calls; + }; + const studio = { id: 's1', kind: 'visualizer', engine: 'shader', name: 'S', shader: 'x', updatedAt: 1 }; + assert.deepStrictEqual(run([md('m1', 1)], { upsert: [md('m2', 2)], remove: [] }), [], 'MilkDrop eklendi: motor sürüyor'); + assert.deepStrictEqual(run([md('m1', 1)], { upsert: [], remove: ['m1'] }), [], 'MilkDrop silindi: motor sürüyor'); + assert.deepStrictEqual(run([md('m1', 1)], { upsert: [studio], remove: [] }), ['dispose', 'apply']); + assert.deepStrictEqual(run([studio], { upsert: [], remove: ['s1'] }), ['dispose', 'apply'], 'silinen Studio presetinin türü önce okunuyor'); + assert.strictEqual(SVP.get('s1'), null); + SVP.setUser([]); +}); + +// ----------------------------------------------------------------- panel + +test('MilkDrop paneli ikinci kopya istemiyor: ortak listeden', async () => { + const key = require.resolve('../src/admin/milkdrop-panel.js'); + delete require.cache[key]; + const cfg = global.window.SV.defaultConfig(); + let asked = 0; + const node = () => { + const n = { kids: [], appendChild(c) { n.kids.push(c); return c; }, setAttribute() {}, removeAttribute() {}, addEventListener() {} }; + Object.defineProperty(n, 'textContent', { get: () => '', set: () => { n.kids = []; } }); + return n; + }; + window.api = { listPresets: () => { asked++; return Promise.resolve([]); } }; + window.SVPanel = { cfg: () => cfg, apply() {}, rerender() {}, el: node, row: node }; + window.SVScenePanels = { miniSlider: node }; + global.document = { querySelector: () => null, getElementById: () => null, activeElement: null }; + SVP.setUser([md('m1', 2), md('m2', 1), { id: 's1', kind: 'visualizer', name: 'S', updatedAt: 3 }]); + const M = require('../src/admin/milkdrop-panel.js'); + M.init(); + M.act('Next'); + assert.strictEqual(cfg.milkdrop.presetId, 'm1', 'listeyi ortak listeden aldı'); + SVP.applyDelta({ upsert: [md('m0', 9)], remove: ['m2'] }); + M.panel(); + M.act('Next'); + assert.strictEqual(cfg.milkdrop.presetId, 'm0', 'değişiklik panelin listesine yansıdı'); + assert.strictEqual(asked, 0, 'panel listeyi ana süreçten istememeli'); + SVP.setUser([]); +}); + +// ------------------------------------------------------------ yayın sunucusu + +const S = require('../src/main/stream-server.js'); + +function get(port, p) { + return new Promise((resolve, reject) => { + http.get({ host: '127.0.0.1', port, path: p }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'] || '', body: Buffer.concat(chunks) })); + }).on('error', reject); + }); +} + +function wsConnect(port, query) { + return new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', port, path: '/ws?' + query, + headers: { + Connection: 'Upgrade', Upgrade: 'websocket', + 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'), 'Sec-WebSocket-Version': '13', + }, + }); + req.on('upgrade', (res, socket, head) => { + const msgs = []; + const waiters = []; + let buf = head; + const pump = () => { + for (;;) { + if (buf.length < 2) return; + const op = buf[0] & 0x0f; + let len = buf[1] & 0x7f; + let off = 2; + if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; } else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; } + if (buf.length < off + len) return; + const payload = buf.subarray(off, off + len); + buf = buf.subarray(off + len); + if (op === 1) msgs.push(JSON.parse(payload.toString('utf8'))); + while (waiters.length) waiters.shift()(); + } + }; + socket.on('data', (d) => { buf = Buffer.concat([buf, d]); pump(); }); + pump(); + const next = async (type) => { + for (;;) { + const i = msgs.findIndex((m) => m.type === type); + if (i >= 0) return msgs.splice(i, 1)[0]; + await new Promise((r) => waiters.push(r)); + } + }; + resolve({ socket, next }); + }); + req.on('response', (res) => reject(new Error('yükseltme yok: ' + res.statusCode))); + req.on('error', reject); + req.end(); + }); +} + +test('yayın sunucusu: MilkDrop kaynakları listede gitmiyor, kimlikle isteniyor', { timeout: 15000 }, async () => { + const LIB = [ + md('md_bir', 2), + { id: 'st_bir', kind: 'visualizer', engine: 'shader', name: 'Studio', shader: 'void main(){}', updatedAt: 1 }, + ]; + const hooks = { + getConfig: () => ({ milkdrop: {}, stream: {} }), + getPresets: () => LIB, + mdPresetSource: (id) => { const p = LIB.find((x) => x.id === id); return p && p.kind === 'milkdrop' ? p.source : null; }, + }; + let port = 0; + for (let i = 0; i < 20 && !port; i++) { + const cand = 20000 + Math.floor(Math.random() * 30000); + const st = await S.start({ enabled: true, port: cand, requireToken: true, token: 'ovl', remoteToken: 'rmt' }, hooks); + if (st.running) port = cand; + } + assert.ok(port, 'boş port bulunamadı'); + try { + const c = await wsConnect(port, 'token=ovl'); + const first = await c.next('presets'); + const m = first.presets.find((p) => p.id === 'md_bir'); + assert.strictEqual(m.source, undefined, 'bağlanınca MilkDrop kaynağı gitmiyor'); + assert.strictEqual(m.lazy, true); + assert.strictEqual(m.name, 'Y - md_bir'); + assert.strictEqual(first.presets.find((p) => p.id === 'st_bir').shader, 'void main(){}', 'Studio preseti olduğu gibi'); + // Değişiklik yayını da soyuluyor; depodaki nesneye dokunulmuyor + const up = md('md_iki', 5); + S.broadcast({ type: 'presets-delta', delta: { upsert: [up], remove: ['md_eski'] } }); + const d = await c.next('presets-delta'); + assert.deepStrictEqual([d.delta.upsert[0].id, d.delta.upsert[0].source, d.delta.upsert[0].lazy, d.delta.remove], ['md_iki', undefined, true, ['md_eski']]); + assert.ok(typeof up.source === 'string' && up.source, 'yayın asıl nesneyi değiştirmemeli'); + c.socket.destroy(); + // Kaynak kimlikle, jetonun arkasında + assert.strictEqual((await get(port, '/milkdrop/preset?id=md_bir')).status, 401); + const ok = await get(port, '/milkdrop/preset?id=md_bir&token=ovl'); + assert.strictEqual(ok.status, 200); + assert.match(ok.type, /^application\/json/); + assert.deepStrictEqual(JSON.parse(ok.body.toString('utf8')), { id: 'md_bir', source: LIB[0].source }); + assert.strictEqual((await get(port, '/milkdrop/preset?id=st_bir&token=ovl')).status, 404, 'yalnız MilkDrop presetleri'); + assert.strictEqual((await get(port, '/milkdrop/preset?id=yok&token=rmt')).status, 404); + } finally { + await S.stop(); + } +}); + +test('yayın sunucusu: soyma yalnız MilkDrop kaynağına', () => { + const out = S.publicPresets([md('a', 1), { id: 'b', kind: 'milkdrop', name: 'kaynaksız' }, null, { id: 'c', kind: 'background', source: 'x' }]); + assert.deepStrictEqual(out.map((p) => p && [p.id, p.source, p.lazy]), [['a', undefined, true], ['b', undefined, undefined], null, ['c', 'x', undefined]]); +}); + +// ------------------------------------------------------------ web köprüsü + +function shim() { + const sockets = []; + class FakeWS { + constructor() { this.readyState = 1; sockets.push(this); } + send() {} + close() {} + } + const fetched = []; + const win = { + location: { search: '?token=t k', pathname: '/', protocol: 'http:', host: 'localhost:1' }, + URLSearchParams, + WebSocket: FakeWS, + document: { documentElement: { setAttribute() {} } }, + setTimeout: () => 0, + Date, + fetch: (url) => { fetched.push(url); return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'x', source: 'KAYNAK' }) }); }, + }; + win.window = win; + vm.runInContext(read('src/web/web-shim.js'), vm.createContext(win), { filename: 'web-shim.js' }); + const msg = (o) => sockets[0].onmessage({ data: JSON.stringify(o) }); + return { win, msg, fetched }; +} + +test('web köprüsü: değişiklik görselleştiriciye, güncel liste kumandaya', async () => { + const { win, msg, fetched } = shim(); + const deltas = []; + const lists = []; + win.api.onPresetsDelta((d) => deltas.push(d)); + win.SVRemote.onPresets((l) => lists.push(Array.from(l, (p) => p.id))); + msg({ type: 'presets', presets: [{ id: 'a', lazy: true }, { id: 'b' }] }); + msg({ type: 'presets-delta', delta: { upsert: [{ id: 'c' }, { id: 'a', name: 'yeni' }], remove: ['b'] } }); + assert.strictEqual(deltas.length, 1); + assert.deepStrictEqual(lists, [['a', 'b'], ['c', 'a']]); + const src = await win.api.milkdropPresetSource('md 1'); + assert.strictEqual(src, 'KAYNAK'); + assert.strictEqual(fetched[0], '/milkdrop/preset?id=md%201&token=t%20k'); +}); + +// ------------------------------------------------------------------ motor + +function engine(api, list) { + const canvas = () => ({ width: 320, height: 240, getContext: (k) => (k === '2d' ? {} : null), addEventListener() {} }); + const ctx = { window: {}, document: { createElement: canvas }, console, performance }; + ctx.window.document = ctx.document; + ctx.window.api = api; + vm.createContext(ctx); + for (const f of ['src/shared/presets.js', 'src/shared/milkdrop.js', 'src/shared/milkdrop-cycle.js', 'src/visualizer/modes/milkdrop.js']) { + vm.runInContext(read(f), ctx, { filename: f }); + } + ctx.window.SVPresets.setUser(list); + return { m: new ctx.window.SVModes.milkdrop(canvas()), win: ctx.window }; +} + +test('motor: kaynağı yolda olan seçim gelene kadar ekrandaki preset sürüyor', async () => { + let resolve = null; + let asked = 0; + const api = { milkdropPresetSource: () => { asked++; return new Promise((r) => { resolve = r; }); } }; + const e = engine(api, [{ id: 'm2', kind: 'milkdrop', name: 'İki', lazy: true, updatedAt: 1 }]); + const cfg = { milkdrop: { presetId: 'm1', source: '[preset00]\nzoom=1.01\n', blendTime: 0 }, milkdropControl: {}, milkdropLibrary: {} }; + e.m._ensurePreset(cfg); + const before = e.m.presetKey; + e.m.autoPick = { id: 'm2', name: 'İki', source: '', lazy: true, cut: false, blend: null, seed: 3 }; + e.m._ensurePreset(cfg); + e.m._ensurePreset(cfg); + assert.strictEqual(e.m.presetKey, before, 'kaynak gelmeden değişmiyor'); + assert.strictEqual(asked, 1, 'kaynak bir kez isteniyor'); + resolve('[preset00]\nzoom=0.99\n'); + await new Promise((r) => setImmediate(r)); + e.m._ensurePreset(cfg); + assert.strictEqual(e.m.presetKey, 'm2|' + '[preset00]\nzoom=0.99\n'.length); + assert.strictEqual(e.win.SVPresets.get('m2').source, '[preset00]\nzoom=0.99\n', 'kaynak listeye yazıldı'); + assert.strictEqual(e.m._lazySource('m2'), '[preset00]\nzoom=0.99\n'); + assert.strictEqual(asked, 1, 'bir daha istenmiyor'); +}); + +test('motor: kaynak alınamazsa seçim bırakılıyor, varsayılana düşülmüyor', async () => { + const api = { milkdropPresetSource: () => Promise.resolve(null) }; + const e = engine(api, [{ id: 'm2', kind: 'milkdrop', name: 'İki', lazy: true, updatedAt: 1 }]); + const cfg = { milkdrop: { presetId: 'm1', source: '[preset00]\nzoom=1.01\n', blendTime: 0 }, milkdropControl: {}, milkdropLibrary: {} }; + e.m._ensurePreset(cfg); + const before = e.m.presetKey; + e.m.autoPick = { id: 'm2', name: 'İki', source: '', lazy: true, cut: false, blend: null, seed: 3 }; + e.m._ensurePreset(cfg); + await new Promise((r) => setImmediate(r)); + e.m._ensurePreset(cfg); + assert.strictEqual(e.m.autoPick, null); + assert.strictEqual(e.m.presetKey, before); +}); + +test('motor: izleyen ve lider seçimi kaynaksız presette "yolda" işaretliyor', () => { + const M = read('src/visualizer/modes/milkdrop.js'); + assert.match(M, /id: f\.id, name: f\.name \|\| '', source: f\.source \|\| '', lazy: !!f\.lazy && !f\.source, cut: !!F\.cut,/); + assert.match(M, /id: p\.id, name: p\.name \|\| '', source: p\.source \|\| '', lazy: !!p\.lazy && !p\.source, cut: this\.cycle\.cut,/); + // Önceden derleme de önce kaynağı istiyor + assert.match(bare(M), /_prefetch\(p\) \{\s*if \(p\.lazy && !p\.source\) \{\s*const s = this\._lazySource\(p\.id\);\s*if \(s === null\) return;/); +}); + +test('pencere, panel ve dışa aktarım kaynakları tam alıyor: soyma yalnız web', () => { + // Dışa aktarım işinde liste depodan olduğu gibi + assert.match(MAIN, /presets: presetsStore\.list\(\),/); + // Soyma yalnız yayın sunucusunda + assert.doesNotMatch(MAIN, /publicPresets/); +}); From 42bf7f142c4e2adaac0b9ee1ac867577bdebc5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 17:56:05 +0300 Subject: [PATCH 2/2] fix(presets): a delete during the background load stays deleted The background load builds its cache from a list of names taken when it starts. A preset deleted after the load had read its file came back in the cache: list() dropped it again on its next folder check, but get() did not, so the web overlay could still be served a deleted preset's source. Deletes made while the load runs are now removed from what it installs, and get() falls back to the file for a preset saved during the load. The race is tested deterministically, with the load's reads held until the delete has happened. The engine also says so once in the console if a page ever receives a sourceless preset with no bridge to fetch the source; today only the web overlay gets sourceless presets. Refs #574, #560 --- README.md | 4 +-- README.tr.md | 4 +-- ROADMAP.md | 19 +++++++----- src/main/presets-store.js | 19 ++++++++++-- src/visualizer/modes/milkdrop.js | 11 ++++++- tests/presets-scale.test.js | 53 ++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0749eb0..9eca975 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#build--distribution) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Tests](https://img.shields.io/badge/tests-2050%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2052%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1246,7 +1246,7 @@ npm test npm start -- --smoke ``` -**2050 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2052 unit tests, all passing.** They are written to check answers, not to exercise lines: - **Formulas** are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor diff --git a/README.tr.md b/README.tr.md index 31ea61f..a82ae74 100644 --- a/README.tr.md +++ b/README.tr.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#paketleme--dağıtım) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Test](https://img.shields.io/badge/test-2050%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2052%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1243,7 +1243,7 @@ npm test npm start -- --smoke ``` -**2050 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2052 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: - **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı diff --git a/ROADMAP.md b/ROADMAP.md index 6639bad..3f382a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2050 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2052 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; 105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at - that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 453 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 455 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1654,12 +1654,15 @@ rest after. No version number yet. - **Not done here:** visualizer windows still hold every source (141 MB with 10,347 presets), since they draw from them directly; the importer itself is part 2. - - **Tests.** 18 tests: the store against a temporary folder (one read, - cache updates, hand-made files, background load with a save in between, - batched saves in the pack's order), the change broadcast on the page, - the stream server stripping sources and serving them behind the token, - the web bridge, the visualizer's rebuild rule and the engine waiting - for a source. 23 of 23 mutations are caught. + - **Tests.** 20 tests: the store against a temporary folder (one read, + cache updates, hand-made files, background load with a save and a + delete in between — the delete made deterministic, since a load that + had already read the file brought it back — and batched saves in the + pack's order), the change broadcast on the page, the stream server + stripping sources and serving them behind the token, the web bridge, + the visualizer's rebuild rule and the engine waiting for a source (and + saying so once if a page ever gets a sourceless preset with no way to + fetch it). 26 of 26 mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/src/main/presets-store.js b/src/main/presets-store.js index cbd7ff1..9515932 100644 --- a/src/main/presets-store.js +++ b/src/main/presets-store.js @@ -58,6 +58,9 @@ function compare(a, b) { let byFile = null; // dosya adı → preset let sorted = null; // sıralı liste; değişince null let warming = null; +/* Arka plan okuması sürerken silinenler: okuma, silmeden ÖNCE aldığı ad + listesinden bir önbellek kuruyor ve silineni geri getirebilirdi. */ +const droppedWhileWarming = new Set(); function names() { try { return fs.readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return []; } @@ -103,6 +106,8 @@ function warm() { } catch { /* okunamayan dosya atlanıyor */ } })); } + for (const n of droppedWhileWarming) map.delete(n); + droppedWhileWarming.clear(); if (!byFile) { byFile = map; sorted = null; } })(); return warming; @@ -131,11 +136,20 @@ function list() { return sorted.slice(); } +/* Tek preset. Önbellekte yoksa dosyasına bakılıyor: arka plan okuması + sürerken kaydedilen preset okumanın ad listesinde yok, `list()` onu + klasör eşitlemesiyle buluyor, `get()` de dosyadan. */ function get(id) { const n = safeName(id); if (!n) return null; ensure(); - return byFile.get(n + '.json') || null; + const name = n + '.json'; + let p = byFile.get(name) || null; + if (!p && fs.existsSync(path.join(dir(), name))) { + p = readOne(name); + if (p) { byFile.set(name, p); sorted = null; } + } + return p; } /* Kaydın hazırlığı: doğrulama, kimlik ve zaman damgası. `stamp` verilirse @@ -179,7 +193,7 @@ function remove(id) { if (!file) return { ok: false, error: 'BAD_ID' }; try { fs.unlinkSync(file); - if (byFile) { byFile.delete(path.basename(file)); sorted = null; } + if (byFile) { byFile.delete(path.basename(file)); sorted = null; } else if (warming) droppedWhileWarming.add(path.basename(file)); return { ok: true }; } catch (e) { return { ok: false, error: e.message }; @@ -229,6 +243,7 @@ function setDir(d) { byFile = null; sorted = null; warming = null; + droppedWhileWarming.clear(); } module.exports = { list, get, save, saveMany, saveManyAsync, remove, dir, warm, compare, setDir }; diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index 3cb29ee..35e463b 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -2072,7 +2072,16 @@ void main(){ const p = S && typeof S.get === 'function' ? S.get(id) : null; if (p && typeof p.source === 'string' && p.source) return p.source; const api = typeof window !== 'undefined' && window.api; - if (!api || typeof api.milkdropPresetSource !== 'function') return ''; + if (!api || typeof api.milkdropPresetSource !== 'function') { + /* Kaynaksız preset ama isteyecek köprü yok. Bugün yalnız web çıkışı + kaynaksız liste alıyor; bir gün başka bir sayfa alırsa seçim + sessizce bırakılmasın, konsolda bir kez görünsün. */ + if (!this._srcWarned) { + this._srcWarned = true; + if (typeof console !== 'undefined' && console.warn) console.warn('[milkdrop] kaynaksız preset, kaynak köprüsü yok: ' + id); + } + return ''; + } const box = this._srcReq || (this._srcReq = new Map()); const r = box.get(id); if (r) return r.done ? r.source : null; diff --git a/tests/presets-scale.test.js b/tests/presets-scale.test.js index 61ba99e..fe7758c 100644 --- a/tests/presets-scale.test.js +++ b/tests/presets-scale.test.js @@ -115,6 +115,38 @@ test('depo: arka planda okuma; bu arada yapılan kayıt kaybolmuyor', async () = assert.strictEqual(ids[0], 'md_arada'); }); +test('depo: arka planda okuma sürerken silinen geri gelmiyor; kaydedilen get ile bulunuyor', async () => { + const d = tmpStore(); + for (let i = 0; i < 20; i++) write(d, md('md_r' + i, i)); + /* Yarış belirlenimli: okuma dosyanın içeriğini HEMEN alıyor, sonucu ise + silme yapıldıktan sonra açılan bir kapıda bekliyor — yani okuma silinen + dosyayı görmüş oluyor. */ + const origRead = fs.promises.readFile; + let open; + const gate = new Promise((r) => { open = r; }); + fs.promises.readFile = function (f, enc) { + let text = null; + try { text = fs.readFileSync(f, enc); } catch (e) { return Promise.reject(e); } + return gate.then(() => text); + }; + let w; + try { + w = STORE.warm(); + await new Promise((r) => setImmediate(r)); + assert.ok(STORE.remove('md_r5').ok); + assert.ok(STORE.save(md('md_yeni', 999)).ok); + open(); + await w; + } finally { + fs.promises.readFile = origRead; + } + // Önce get: list() klasörü eşitleyip eksiği kendisi kapatırdı + const yeni = STORE.get('md_yeni'); + assert.strictEqual(yeni && yeni.name, 'Y - md_yeni', 'okuma sırasında kaydedilen get ile de bulunuyor'); + assert.strictEqual(STORE.get('md_r5'), null, 'silinen önbellekte dirilmemeli'); + assert.ok(!STORE.list().some((p) => p.id === 'md_r5')); +}); + test('depo: toplu kayıt parça parça, paketin sırasıyla, ilerlemeyle', async () => { tmpStore(); /* Kimlikler bilerek AZALAN sırada: sıra kimlikten değil paketten gelmeli @@ -435,6 +467,27 @@ test('motor: kaynak alınamazsa seçim bırakılıyor, varsayılana düşülmüy assert.strictEqual(e.m.presetKey, before); }); +test('motor: kaynaksız preset ama köprü yok — seçim bırakılıyor ve konsolda bir kez söyleniyor', () => { + const e = engine({}, [{ id: 'm2', kind: 'milkdrop', name: 'İki', lazy: true, updatedAt: 1 }]); + const warned = []; + const saved = console.warn; + console.warn = (m) => warned.push(String(m)); + try { + const cfg = { milkdrop: { presetId: 'm1', source: '[preset00]\nzoom=1.01\n', blendTime: 0 }, milkdropControl: {}, milkdropLibrary: {} }; + e.m._ensurePreset(cfg); + const before = e.m.presetKey; + for (let i = 0; i < 3; i++) { + e.m.autoPick = { id: 'm2', name: 'İki', source: '', lazy: true, cut: false, blend: null, seed: 3 }; + e.m._ensurePreset(cfg); + } + assert.strictEqual(e.m.autoPick, null); + assert.strictEqual(e.m.presetKey, before, 'varsayılana düşülmedi'); + assert.deepStrictEqual(warned, ['[milkdrop] kaynaksız preset, kaynak köprüsü yok: m2']); + } finally { + console.warn = saved; + } +}); + test('motor: izleyen ve lider seçimi kaynaksız presette "yolda" işaretliyor', () => { const M = read('src/visualizer/modes/milkdrop.js'); assert.match(M, /id: f\.id, name: f\.name \|\| '', source: f\.source \|\| '', lazy: !!f\.lazy && !f\.source, cut: !!F\.cut,/);