diff --git a/README.md b/README.md
index 8cc0fdc..fb855c8 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
[](#build--distribution)
[](https://www.electronjs.org/)
[](https://github.com/CaYatur/SoundVisualizer/releases)
-[](#tests)
+[](#tests)
[](https://cayadev.com)
@@ -360,6 +360,15 @@ that asserts the bar profile has no step in it.
scanned in full before you are asked. No preset pack ships with the app. Measured with real
packs: a 9,795-preset folder was scanned in 1.1 s and imported in 6.2 s, and importing it again
added nothing.
+- **Preset thumbnails.** The MilkDrop panel's list has a *Grid* layout: every preset shows a small
+ picture of itself, so a preset can be found by its look. Each thumbnail is drawn once in the
+ background — the first two seconds of the preset with the demo sound, from a black screen — kept
+ in the app's data, and drawn again when the preset or a texture it uses changes. Only the
+ presets you scroll to are drawn. A visualizer window drawing MilkDrop at the same time barely
+ noticed: none of its frames took longer than 27 ms. Found while building this: when another
+ MilkDrop window was drawing and the computer was busy, a MilkDrop picture that had just set up its
+ buffers (a new window, a resize, a thumbnail, a video export) could start from the other window's
+ picture. Buffers now start from zeros.
- **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
@@ -1259,7 +1268,7 @@ npm test
npm start -- --smoke
```
-**2081 unit tests, all passing.** They are written to check answers, not to exercise lines:
+**2108 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 605d9e3..91d763d 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -12,7 +12,7 @@
[](#paketleme--dağıtım)
[](https://www.electronjs.org/)
[](https://github.com/CaYatur/SoundVisualizer/releases)
-[](#testler)
+[](#testler)
[](https://cayadev.com)
@@ -353,6 +353,15 @@ profilinde basamak olmadığını doğruluyor.
baştan taranıyor. Uygulamayla hiçbir preset paketi gelmiyor. Gerçek paketlerle ölçüldü: 9.795
presetlik bir klasör 1,1 sn'de tarandı, 6,2 sn'de içe aktarıldı; ikinci kez almak hiçbir şey
eklemedi.
+- **Preset küçük resimleri.** MilkDrop panelindeki listenin *Izgara* düzeni var: her preset kendi
+ küçük resmiyle görünüyor, yani bir preset görünüşünden bulunabiliyor. Her küçük resim arka planda
+ bir kez çiziliyor — presetin ilk iki saniyesi, örnek sesle, siyah bir ekrandan — uygulamanın
+ verisinde saklanıyor ve preset ya da kullandığı bir doku değişince yeniden çiziliyor. Yalnız
+ kaydırıp baktığınız presetler çiziliyor. Aynı anda MilkDrop çizen bir görselleştirici penceresi
+ bunu neredeyse hiç fark etmedi: hiçbir karesi 27 ms'den uzun sürmedi. Bunu yaparken bulundu: başka
+ bir MilkDrop penceresi çizerken ve bilgisayar meşgulken, tamponlarını yeni kuran bir MilkDrop
+ görüntüsü (yeni bir pencere, yeniden boyutlandırma, küçük resim, video dışa aktarımı) öbür
+ pencerenin görüntüsüyle başlayabiliyordu. Tamponlar artık sıfırdan başlıyor.
- **`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
@@ -1256,7 +1265,7 @@ npm test
npm start -- --smoke
```
-**2081 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar:
+**2108 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 d0af202..799671a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -129,9 +129,9 @@ npm test
npm start -- --smoke
```
-- **2081 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0;
+- **2108 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 484
+ that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 511
on `main` since.
Formulas are checked against values derived
by hand from their definitions — Viviani's curve staying on its sphere, the
@@ -1737,6 +1737,127 @@ rest after. No version number yet.
main-process handlers, the engine's texture counter, and the panel
drawn with a fake DOM, confirmation and rescan included. 57 of 57
mutations are caught.
+- **Preset thumbnails (#575)** · done. The MilkDrop panel's list gets a
+ *Layout* choice, *List* or *Grid*. In the grid every preset shows a
+ thumbnail, drawn once in a hidden window and kept in the user data
+ (`milkdrop-thumbs/`).
+ - **Recipe, measured** on a seeded 200-preset sample of the corpus: a
+ black start; 60 frames of the demo sound at 1/30 s; drawn at 640x360
+ and scaled to a 256x144 WebP (~3.6 KB). A fixed seed image (a builtin
+ preset for 45 frames, then a hard cut) lowered the black thumbnails only
+ from 20 to 18 — fixing four and breaking two — made each job 45% longer
+ and painted its own colours into the thumbnails of presets that draw
+ nothing like it. 90 and 150 frames gave 20 and 22 black thumbnails (some
+ presets fade out), at a median of 350 and 538 ms against 259 ms. Drawing
+ at 640x360 instead of 320x180 barely moved the time (250 against 259 ms
+ median): the work is the per-frame code, not the pixels.
+ - **The same every time.** Each thumbnail is drawn by a new engine
+ instance, whose context `dispose` releases, from black buffers.
+ `Math.random` and the preset's seed come from the key. A texture the
+ preset asks for is waited for right after the frame that asks, and a
+ thumbnail whose texture does not arrive within 8 s is not written;
+ neither is one whose frames were not all drawn on a live context (with
+ no context the engine draws a placeholder, after a loss it keeps the
+ last frame — either would be kept for good under a content key). A
+ crashed drawing process is replaced instead of making every later cell
+ wait for the 30 s limit. Checked in the real page: seven presets, each
+ drawn after two different busy presets, gave the same bytes both times
+ — the five busiest of 40 (6.8–13 KB), a textured one and one using
+ `sampler_randNN`. Also checked under load (below): one preset drawn 48
+ times with a visualizer window drawing MilkDrop and the CPU busy gave
+ the same bytes every time.
+ - **Key** = a hash of the recipe (the bytes of every file the thumbnail
+ page loads, plus the sizes and the frame count) + the preset's source +
+ a signature of the textures it asks for (name, size, modification time;
+ the whole list for `sampler_randNN`). An engine change (#580, #567)
+ therefore retires every thumbnail by itself, an edited preset or a
+ changed texture gets a new one, and two presets with the same content
+ share one. Once per session, files no current preset produces are
+ deleted.
+ - **Panel.** The visible cells are asked for; cached thumbnails come back
+ at once, the rest as they are drawn, newest request first, one at a
+ time. A waiting cell is striped and a failed one has a dashed frame, so
+ neither looks like a thumbnail that is really black (about 9% are). The
+ layout is the viewer's own choice, kept in browser storage rather than
+ in the settings. Images come through an `sv-thumb:` protocol that
+ accepts only a key and reads only the thumbnail folder. The hidden
+ window closes after 20 s idle and when the panel closes — otherwise it
+ would keep the app from quitting.
+ - **Found on the way — a picture from another window in a new buffer.**
+ The engine allocated its render targets empty and cleared them with
+ `gl.clear`. With another MilkDrop context drawing on the same GPU and
+ the CPU busy, a freshly allocated feedback buffer could still hold that
+ context's picture, and the feedback loop grew it: a preset whose
+ thumbnail is black came out as the starburst the visualizer window was
+ showing at the time, in 13 of 24 draws with the flash limiter off and 3
+ of 16 with it on. Neither condition alone did it (18 of 18 clean with
+ the window drawing bars under load, and with the window drawing MilkDrop
+ and the CPU idle). The same GPU, context attributes and formats were
+ reported every time. Targets are now allocated from zeros — a shared
+ zero buffer that is never written (8 MB at 1080p) — and the clear
+ stays; the same conditions gave 0 of 48. This is not only about
+ thumbnails: any window whose buffers are allocated again (a second
+ window opening, a resize, a restored context) and the video export
+ could start from another window's picture. Drawing alone is unchanged:
+ 200 presets gave identical results before and after. The price is paid
+ only when buffers are allocated: a full rebuild of the targets at
+ 1920x1080 (feedback, blur and flash limiter) takes 8.6 ms instead of
+ 3.3 ms, less than one frame.
+ - **Found on the way, smaller:** `sampler_randNN` picked from the texture
+ list in the order the file system returned — alphabetical on Windows,
+ arbitrary on Linux — so the same preset could get another texture on
+ another machine; the list is now sorted the way the measurement script
+ sorts it. The importer (#574) did not strip the filter/wrap prefix from
+ sampler names, so a preset asking for `sampler_fw_worms` did not bring
+ the `worms.jpg` beside it; now it does. The builtin presets module gave
+ Node nothing; the main process now reads their sources from it. The
+ light colour sampler (#589) read its 64x16 canvas with `getImageData`
+ about 30 times a second and Chromium warned about it — the smoke test
+ caught the warning once lights were set to take MilkDrop's colours; the
+ downscale stays on the GPU and only the 4 KB result is read back from a
+ canvas made for reading.
+ - **Found on the way — the smoke test could drive real lights.** The smoke
+ test and the screenshot tool run on the user's own profile. With
+ Dynamic Lighting on in the settings, the app drove the lights at
+ start-up in those runs too, and the smoke test itself turns OpenRGB on
+ in the panel. As with the camera, which automation never opens, the
+ physical outputs are now off in these runs: every Dynamic Lighting
+ setting goes through one function that sends it switched off, the
+ device scan is skipped, and OpenRGB and Art-Net are never started.
+ Diagnostics (`--diag`) still drives the lights, since that is what it
+ is for.
+ - **Measured** in an isolated copy with 120 corpus presets and the panel
+ driven: the first thumbnail after 1.0 s, the 20 visible cells full in
+ 10–17 s. With a visualizer window drawing MilkDrop at 75 Hz, 20 s idle
+ against 20 s while 31 thumbnails were drawn: frame time p99 13.5 against
+ 13.6 ms, longest frame 17.2 against 26.7 ms, none over 33 ms either way
+ — so no pause between jobs was added. Asking for 40 cached thumbnails
+ took 2.9 ms (median round trip from the panel) with no texture folder
+ and 4.2 ms with the corpus texture pack chosen, so the texture listing
+ is not cached. The English UI was checked.
+ - **Not done:** thumbnails in the web remote and in the list view;
+ drawing every thumbnail ahead of time (only what is looked at is drawn;
+ 10,000 presets would take over an hour); a thumbnail shows the first two
+ seconds, not a preset's later look. Thumbnails always use the engine's
+ defaults (MilkDrop fidelity on, mesh 64, smooth lines, flash limiter
+ on): with other settings the live picture differs a little.
+ - **Tests.** 22 tests: recipe and page files, key and texture signature,
+ the queue (cache, one job per key, newest first, failure, staleness,
+ cap, clear), cleanup, the protocol, the page driver with a fake engine
+ (new instance per job, seeding, texture wait and timeout, no or lost
+ context, a frame not drawn), the builtins
+ in Node, the main-process pins, and the panel's grid with a fake DOM.
+ The smoke test draws a builtin preset's thumbnail through the whole
+ chain — the recipe files read from the package, the hidden window, the
+ protocol and the panel's CSP — into a temporary folder, not the user's
+ data. One engine test drives `_makeTarget` against a fake GL: a zero
+ array of the right type and length for both formats, the unpack
+ alignment set first, the clear kept, the buffer shared and grown. One
+ checks that the light sampler reads only the small canvas made for
+ reading, and three check that every path to the lights passes the
+ automation flag. 57 of 58 mutations are caught; the survivor removes
+ the "no context" check, which the frame counter already makes (without
+ a context no frame is drawn).
## v3.1.6 — Comprehensive video export
diff --git a/src/admin/index.html b/src/admin/index.html
index 163d6d5..5af3cc0 100644
--- a/src/admin/index.html
+++ b/src/admin/index.html
@@ -2,7 +2,7 @@
-
+
Ses Görselleştirici — Yönetici Paneli
diff --git a/src/admin/milkdrop-panel.js b/src/admin/milkdrop-panel.js
index 0e25817..784e288 100644
--- a/src/admin/milkdrop-panel.js
+++ b/src/admin/milkdrop-panel.js
@@ -297,6 +297,120 @@
P().rerender();
}
+ /* KÜÇÜK RESİMLER (#575). Izgara görünümünde her hücrenin küçük resmi ana
+ süreçten geliyor: görünen hücrelerin kimlikleri isteniyor, hazır
+ olanların anahtarı hemen dönüyor, gerisi çizildikçe olayla. Görsel
+ `sv-thumb://t/.webp`. Görünüm (liste / ızgara) bu
+ görüntüleyicinin tercihi: ayara değil tarayıcı deposuna yazılıyor.
+ thumbKeys: kimlik → anahtar ('' = çizilemedi); çizimler arasında
+ kalıyor. Preset değişince ya da silinince (değişiklik yayını) kaydı,
+ doku klasörü ya da içe aktarılan dokular değişince hepsi düşüyor.
+ Bekleyen hücre çizgili, çizilemeyen kesikli çerçeveli: ikisi de siyah
+ bir küçük resimden ayrı görünmeli — presetlerin ~%9'u gerçekten siyah
+ başlıyor. */
+ const VIEW_KEY = 'sv-md-view';
+ let viewMode = (() => {
+ try { return localStorage.getItem(VIEW_KEY) === 'grid' ? 'grid' : 'list'; } catch (e) { return 'list'; }
+ })();
+ const thumbKeys = new Map();
+ const thumbWanted = new Set();
+ let thumbTexSig = null;
+ let thumbObserver = null;
+ let thumbTimer = null;
+ let thumbListening = false;
+ // Gözlemci (IntersectionObserver) yoksa istenen ilk hücre sayısı
+ const THUMB_FIRST = 24;
+ const thumbUrl = (key) => 'sv-thumb://t/' + key + '.webp';
+
+ function setView(v) {
+ viewMode = v === 'grid' ? 'grid' : 'list';
+ try { localStorage.setItem(VIEW_KEY, viewMode); } catch (e) { /* depo yok: bu oturumluk */ }
+ }
+
+ function listenThumbs() {
+ if (thumbListening || !window.api) return;
+ thumbListening = true;
+ if (window.api.onMilkdropThumb) {
+ window.api.onMilkdropThumb((t) => {
+ if (!t || typeof t.id !== 'string') return;
+ // Beklerken anahtarı eskidi (dokular değişti): yeni anahtarla yeniden
+ if (t.stale) { thumbKeys.delete(t.id); paintThumb(thumbBox(t.id), undefined); wantThumb(t.id); return; }
+ setThumb(t.id, t.ok ? String(t.key || '') : '');
+ });
+ }
+ if (window.api.onPresetsDelta) {
+ window.api.onPresetsDelta((d) => {
+ for (const p of (d && d.upsert) || []) if (p && p.id) thumbKeys.delete(p.id);
+ for (const id of (d && d.remove) || []) thumbKeys.delete(id);
+ });
+ }
+ }
+
+ function thumbBox(id) {
+ if (typeof document === 'undefined') return null;
+ const root = (document.getElementById && document.getElementById('sections')) || document;
+ if (!root || !root.querySelectorAll) return null;
+ for (const b of root.querySelectorAll('.md-thumb-box')) if (b.getAttribute('data-id') === id) return b;
+ return null;
+ }
+
+ // Hücrenin durumu: anahtar (görsel), '' (çizilemedi) ya da undefined (bekliyor)
+ function paintThumb(box, key) {
+ if (!box) return;
+ const img = box.querySelector && box.querySelector('img');
+ box.classList.remove('pending', 'failed');
+ if (key) {
+ if (img && img.getAttribute('src') !== thumbUrl(key)) img.setAttribute('src', thumbUrl(key));
+ box.removeAttribute('title');
+ return;
+ }
+ if (img) img.removeAttribute('src');
+ box.classList.add(key === '' ? 'failed' : 'pending');
+ box.setAttribute('title', tr(key === '' ? 'Küçük resim çizilemedi' : 'Küçük resim hazırlanıyor…'));
+ }
+
+ function setThumb(id, key) {
+ thumbKeys.set(id, key);
+ paintThumb(thumbBox(id), key);
+ }
+
+ function wantThumb(id) {
+ if (!id) return;
+ thumbWanted.add(id);
+ clearTimeout(thumbTimer);
+ thumbTimer = setTimeout(flushThumbs, 80);
+ }
+
+ async function flushThumbs() {
+ thumbTimer = null;
+ const ids = Array.from(thumbWanted);
+ thumbWanted.clear();
+ if (!ids.length || !window.api || !window.api.milkdropThumbs) return;
+ listenThumbs();
+ let r = null;
+ try { r = await window.api.milkdropThumbs(ids); } catch (e) { r = null; }
+ if (!r || !r.ok || !r.ready) return;
+ for (const id of Object.keys(r.ready)) setThumb(id, String(r.ready[id] || ''));
+ }
+
+ /* Her çizimde gözlemci yeniden kuruluyor: panel düğümleri baştan
+ yaratıyor. Görünen hücreler (biraz payla) isteniyor; ana süreç aynı
+ anahtarı bir kez çiziyor, önbellektekini hemen döndürüyor — yani görünen
+ her hücreyi her çizimde yeniden istemek ucuz ve dışarıda değişen bir
+ doku da böylece fark ediliyor. */
+ function watchThumbs(grid) {
+ if (thumbObserver) { try { thumbObserver.disconnect(); } catch (e) { /* yok say */ } thumbObserver = null; }
+ const boxes = grid && grid.querySelectorAll ? Array.from(grid.querySelectorAll('.md-thumb-box')) : [];
+ if (typeof IntersectionObserver !== 'function') {
+ boxes.slice(0, THUMB_FIRST).forEach((b) => wantThumb(b.getAttribute('data-id')));
+ return;
+ }
+ thumbObserver = new IntersectionObserver((entries) => {
+ for (const en of entries) if (en.isIntersecting) wantThumb(en.target.getAttribute('data-id'));
+ }, { root: grid, rootMargin: '160px 0px' });
+ boxes.forEach((b) => thumbObserver.observe(b));
+ }
+
async function importPack() {
const IS = window.SVPresets;
const L = LB();
@@ -1335,9 +1449,21 @@
}
}
- // Liste
+ /* DÜZEN (#575): liste ya da küçük resimli ızgara. Seçmek yapılandırma
+ göndermiyor, yalnız paneli yeniden çiziyor. */
+ const viewSel = el('select', { class: 'sel' });
+ for (const [v, label] of [['list', 'Liste'], ['grid', 'Izgara']]) {
+ const o = el('option', { value: v, text: tr(label) });
+ if (v === viewMode) o.selected = true;
+ viewSel.appendChild(o);
+ }
+ viewSel.addEventListener('change', () => { setView(viewSel.value); listScroll = 0; P().rerender(); });
+ nodes.push(P().row('Düzen', viewSel));
+ const grid = viewMode === 'grid';
+
+ // Liste ya da ızgara
const list = el('div', {
- class: 'md-list',
+ class: grid ? 'md-grid' : 'md-list',
onscroll: (e) => { listScroll = e.target.scrollTop; },
});
/* Arama ve süzgeç satırları az presette gizli; görünmeyen bir süzgeç
@@ -1346,6 +1472,26 @@
const vis = visible(cfg);
const LBn = LB();
const libNow = library(cfg);
+ const keepScroll = () => {
+ const box = document.querySelector(grid ? '.md-grid' : '.md-list');
+ if (box) listScroll = box.scrollTop;
+ };
+ const removePreset = async (p) => {
+ 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));
+ refresh(() => rerender());
+ };
+ if (grid) {
+ // Doku klasörü ya da içe aktarılan dokular değiştiyse bütün anahtarlar eski
+ const tsig = (md.textureDir || '') + '#' + (+libNow.textureRev || 0);
+ if (thumbTexSig !== null && thumbTexSig !== tsig) thumbKeys.clear();
+ thumbTexSig = tsig;
+ listenThumbs();
+ }
if (!vis.length) {
list.appendChild(el('div', {
class: 'studio-note',
@@ -1360,26 +1506,47 @@
const active = md.presetId === p.id;
// Favori ve etiketler (#576): yıldız tek tıkla, etiketler adın yanında
const fav = !!(LBn && LBn.isFavorite(libNow, p.id));
+ const favBtn = LBn ? el('button', {
+ class: 'md-fav' + (fav ? ' on' : ''), type: 'button', text: fav ? '★' : '☆',
+ title: fav ? 'Favorilerden çıkar' : 'Favorilere ekle',
+ 'aria-pressed': fav ? 'true' : 'false',
+ onclick: () => { keepScroll(); setFavorite(p.id, !fav); },
+ }) : null;
+ if (grid) {
+ /* Hücre: küçük resim, ad, yıldız ve (kullanıcı presetinde) silme.
+ Dosyası gitmiş bir anahtarın görseli yüklenemezse yeniden isteniyor. */
+ const key = thumbKeys.get(p.id);
+ const img = el('img', {
+ alt: '', draggable: 'false',
+ onerror: () => {
+ if (!thumbKeys.get(p.id)) return;
+ thumbKeys.delete(p.id);
+ paintThumb(thumbBox(p.id), undefined);
+ wantThumb(p.id);
+ },
+ });
+ if (key) img.setAttribute('src', thumbUrl(key));
+ const box = el('div', { class: 'md-thumb-box', 'data-id': p.id }, [img]);
+ paintThumb(box, key);
+ list.appendChild(el('div', { class: 'md-cell' + (active ? ' active' : '') }, [
+ el('button', {
+ class: 'md-cell-main', type: 'button', title: tr(p.name || p.id),
+ onclick: () => { keepScroll(); load(cfg, p); rerender(); },
+ }, [box, el('span', { class: 'md-cell-name', text: tr(p.name || p.id) })]),
+ favBtn,
+ p.builtin ? null : el('button', {
+ class: 'btn ghost tiny danger md-cell-del', type: 'button', text: '✕', title: 'Sil',
+ onclick: () => removePreset(p),
+ }),
+ ]));
+ return;
+ }
const tags = LBn ? LBn.tagsOf(libNow, p.id) : [];
list.appendChild(el('div', { class: 'md-item' + (active ? ' active' : '') }, [
- LBn ? el('button', {
- class: 'md-fav' + (fav ? ' on' : ''), type: 'button', text: fav ? '★' : '☆',
- title: fav ? 'Favorilerden çıkar' : 'Favorilere ekle',
- 'aria-pressed': fav ? 'true' : 'false',
- onclick: () => {
- const listEl = document.querySelector('.md-list');
- if (listEl) listScroll = listEl.scrollTop;
- setFavorite(p.id, !fav);
- },
- }) : null,
+ favBtn,
el('button', {
class: 'md-name', type: 'button', text: tr(p.name || p.id),
- onclick: () => {
- const listEl = document.querySelector('.md-list');
- if (listEl) listScroll = listEl.scrollTop;
- load(cfg, p);
- rerender();
- },
+ onclick: () => { keepScroll(); load(cfg, p); rerender(); },
}),
tags.length ? el('span', { class: 'md-tagline', text: tags.map((t) => '#' + t).join(' ') }) : null,
/* Yerleşiğin silme düğmesi YOK: dosyası olmadığı için `deletePreset`
@@ -1389,15 +1556,7 @@
? el('span', { class: 'md-builtin', text: 'yerleşik', title: 'CAYADEV presetleri' })
: el('button', {
class: 'btn ghost tiny danger', type: 'button', text: '✕', title: 'Sil',
- 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));
- refresh(() => rerender());
- },
+ onclick: () => removePreset(p),
}),
]));
});
@@ -1405,10 +1564,17 @@
if (listScroll > 0) {
setTimeout(() => {
list.scrollTop = listScroll;
- const active = list.querySelector('.md-item.active');
+ const active = list.querySelector(grid ? '.md-cell.active' : '.md-item.active');
if (active) active.scrollIntoView({ block: 'nearest' });
}, 0);
}
+ if (grid) {
+ setTimeout(() => watchThumbs(list), 0);
+ nodes.push(el('div', {
+ class: 'studio-note dim-hint',
+ text: 'Küçük resim, presetin ilk iki saniyesi: örnek sesle, siyah bir ekrandan başlanarak çiziliyor. Her preset bir kez çiziliyor ve saklanıyor; preset değişince yeniden çiziliyor.',
+ }));
+ }
if (vis.length > 400) {
nodes.push(el('div', { class: 'studio-note dim-hint', text: vis.length + ' presetten ilk 400 gösteriliyor; aramayı daraltın.' }));
}
diff --git a/src/admin/studio.css b/src/admin/studio.css
index c47991c..1ededb6 100644
--- a/src/admin/studio.css
+++ b/src/admin/studio.css
@@ -532,6 +532,96 @@ input.p-in[type='color'] { padding: 2px; height: 28px; width: 46px; flex: none;
}
.md-item { display: flex; align-items: center; gap: 4px; border-radius: 6px; }
.md-item.active { background: rgba(124, 92, 255, 0.18); }
+/* Küçük resimli ızgara (#575): listeyle aynı çerçeve. Hücre 16:9 küçük
+ resim + ad; yıldız ve silme köşede. Bekleyen hücre çizgili, çizilemeyen
+ kesikli çerçeveli — ikisi de siyah bir küçük resimden ayrı görünmeli:
+ presetlerin bir kısmı gerçekten siyah başlıyor. */
+.md-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
+ gap: 6px;
+ max-height: 420px;
+ overflow-y: auto;
+ border: 1px solid var(--line, rgba(255, 255, 255, 0.08));
+ border-radius: 8px;
+ padding: 6px;
+}
+.md-cell { position: relative; min-width: 0; }
+.md-cell-main {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+ padding: 4px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.md-cell-main:hover { background: rgba(255, 255, 255, 0.05); }
+.md-cell.active .md-cell-main {
+ background: rgba(124, 92, 255, 0.18);
+ border-color: rgba(124, 92, 255, 0.55);
+}
+.md-thumb-box {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ overflow: hidden;
+ border-radius: 5px;
+ background: #000;
+}
+.md-thumb-box img { display: block; width: 100%; height: 100%; object-fit: cover; }
+.md-thumb-box.pending img,
+.md-thumb-box.failed img { display: none; }
+.md-thumb-box.pending {
+ background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0 6px, rgba(255, 255, 255, 0.02) 6px 12px);
+ animation: md-thumb-wait 1.6s ease-in-out infinite;
+}
+@keyframes md-thumb-wait { 50% { opacity: 0.55; } }
+@media (prefers-reduced-motion: reduce) {
+ .md-thumb-box.pending { animation: none; }
+}
+.md-thumb-box.failed {
+ background: rgba(255, 255, 255, 0.03);
+ outline: 1px dashed rgba(255, 138, 138, 0.55);
+ outline-offset: -1px;
+}
+.md-thumb-box.failed::after {
+ content: '✕';
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: rgba(255, 138, 138, 0.8);
+ font-size: 14px;
+}
+.md-cell-name {
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.md-cell .md-fav {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ padding: 2px 5px;
+ background: rgba(0, 0, 0, 0.55);
+ border-radius: 999px;
+}
+.md-cell .md-cell-del {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ opacity: 0;
+ transition: opacity 0.12s;
+}
+.md-cell:hover .md-cell-del,
+.md-cell .md-cell-del:focus-visible { opacity: 1; }
/* Sprite'lar (#577): numara, ad, varsa neden başlamadığı, ▶ ve ■. Preset
listesiyle aynı çerçeve; başlatılamayan satır soluk. */
.md-sprites {
diff --git a/src/main/main.js b/src/main/main.js
index 8f35b86..60dbd61 100644
--- a/src/main/main.js
+++ b/src/main/main.js
@@ -33,6 +33,8 @@ const logoLibrary = require('./logo-library');
protocol.registerSchemesAsPrivileged([
{ scheme: 'sv-media', privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true, bypassCSP: false } },
{ scheme: 'sv-logo', privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true, bypassCSP: false } },
+ // MilkDrop küçük resimleri (#575): yalnız anahtar, yalnız kendi klasörü
+ { scheme: 'sv-thumb', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: false } },
]);
/* Arayüz dili.
@@ -88,6 +90,16 @@ const SHOTS_ONLY = (process.argv.find((a) => a.startsWith('--only=')) || '').sli
bekçisini açmaz (#564): bir sürüm kapısı o an masada ne açık olduğuna
bağlı olamaz. */
const AUTOMATION_RUN = SMOKE || SHOTS || process.argv.includes('--diag') || !!process.env.SV_IDENTITY_PROBE_FILE;
+/* OTOMASYON FİZİKSEL IŞIKLARI SÜRMEZ (#575'te bulundu). Öz test ve ekran
+ görüntüsü üreticisi gerçek kullanıcı klasörüyle koşuyor. Kullanıcının
+ ayarında Dynamic Lighting açıksa açılışta cihazları sürüyordu; öz testin
+ kendi adımları da panelde OpenRGB'yi açıyor — bilgisayarda OpenRGB
+ çalışıyorsa onun cihazlarını, Art-Net açıksa ağdaki sahne ışıklarını
+ sürerdi. Kamera için kural zaten var (otomasyonda hiç açılmıyor); ışıklar
+ için de aynısı: bu kiplerde donanıma giden çağrılar kapalı (bkz.
+ `lightingSet`, `syncArtnet`, `syncOpenRgb`). Tanılama (`--diag`) ışıkları
+ gerçekten sınamak için var; ona dokunulmuyor. */
+const HW_OFF = SMOKE || SHOTS;
/* Electron 35, console-message olayinin imzasini degistirdi: eskiden
(event, level, message, line, sourceId) geliyordu ve level bir sayiydi
(0 verbose, 1 info, 2 warning, 3 error); artik tum alanlar olay nesnesinin
@@ -420,8 +432,8 @@ function syncPortableLightingFocus() {
const lighting = currentConfig?.lighting;
if (!lighting?.enabled) return;
const focused = BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isFocused());
- if (focused) dynamicLighting.setConfig(lighting).catch(() => {});
- else dynamicLighting.setConfig({ ...lighting, enabled: false }).catch(() => {});
+ if (focused) lightingSet(lighting).catch(() => {});
+ else lightingSet({ ...lighting, enabled: false }).catch(() => {});
}, 120);
}
app.on('browser-window-focus', syncPortableLightingFocus);
@@ -1017,14 +1029,17 @@ function closeVisualizer(displayId) {
bildirilen "kapattım ama arka planda açık kalıyor" hatasının nedeni buydu.
- Dışa aktarma render penceresi: normalde finalizeExport() yok eder, ama
dışa aktarma sürerken panel kapatılırsa geride kalır ve aynı sonucu verir.
+ - MilkDrop küçük resim penceresi (#575): boşta kalınca kendisi kapanıyor,
+ ama panel çizim sürerken kapatılırsa ayakta kalırdı.
Panel kapandığında gösteri biter — closeVisualizer() de aynı yerde çağrılıyor —
- bu yüzden bu ikisinin ayakta kalması için bir sebep yok. */
+ bu yüzden bunların ayakta kalması için bir sebep yok. */
function closeHelperWindows() {
textureShare.stop().catch(() => {});
/* Yarım kalan dışa aktarmayı düzgün sonlandırır: ffmpeg'i öldürür, pencereyi
yok eder, yarım dosyayı siler. Dışa aktarma yoksa sessizce döner. */
finalizeExport('cancelled');
+ closeThumbWin();
}
// ----------------------------------------------------------------------------
@@ -1424,7 +1439,7 @@ function applyIncomingConfig(config, opts) {
const prevSee = wantsTransparent();
currentConfig = config;
if (save) saveSettings(config);
- dynamicLighting.setConfig(config?.lighting).catch(() => {});
+ lightingSet(config?.lighting).catch(() => {});
const nowSee = wantsTransparent();
/* Sahne/preset değişimi her zaman hemen gitsin. Şeffaflık kromu için
pencere yeniden kurulacaksa bile önce canlı pencere yeni sahneyi
@@ -1506,9 +1521,18 @@ ipcMain.on('preview:subscribe', (e, on) => {
// Görselleştirici açıldığında mevcut yapılandırmayı ister
ipcMain.handle('request-config', () => currentConfig);
-ipcMain.handle('lighting:scan', () => dynamicLighting.scan());
+/* Dynamic Lighting'e giden her ayar buradan geçiyor: otomasyonda ışıklar
+ hep kapalı gidiyor (bkz. HW_OFF). Tarama da cihazlara bir anlığına el
+ koyuyor; otomasyonda hiç yapılmıyor. */
+function lightingSet(lighting) {
+ return dynamicLighting.setConfig(HW_OFF ? Object.assign({}, lighting, { enabled: false }) : lighting);
+}
+
+ipcMain.handle('lighting:scan', () => (HW_OFF
+ ? { ok: true, supported: false, devices: [], automation: true }
+ : dynamicLighting.scan()));
ipcMain.handle('lighting:availability', () => dynamicLighting.availability());
-ipcMain.handle('lighting:apply', (e, lighting) => dynamicLighting.setConfig(lighting));
+ipcMain.handle('lighting:apply', (e, lighting) => lightingSet(lighting));
ipcMain.handle('lighting:identity-status', () => lightingIdentity.status(dynamicLighting));
ipcMain.handle('lighting:open-settings', () => lightingIdentity.openDynamicLightingSettings());
@@ -1923,7 +1947,7 @@ function applyRemoteCommand(msg, client) {
sendToVisualizers('config', currentConfig);
notifyAdmin('external-config', currentConfig); // panel kendi kopyasını tazelesin
streamServer.broadcast({ type: 'config', config: currentConfig });
- dynamicLighting.setConfig(currentConfig.lighting).catch(() => {});
+ lightingSet(currentConfig.lighting).catch(() => {});
}
let streamSyncing = false;
@@ -1973,7 +1997,8 @@ function syncStreamServer() {
function syncArtnet() {
const a = (currentConfig && currentConfig.artnet) || {};
- if (!a.enabled) return artnet.stop().then(() => artnet.status());
+ // Otomasyonda ağdaki sahne ışıklarına paket gitmiyor (bkz. HW_OFF)
+ if (!a.enabled || HW_OFF) return artnet.stop().then(() => artnet.status());
return artnet.start(a).then((st) => {
notifyAdmin('artnet-status', st);
return st;
@@ -2005,7 +2030,8 @@ function syncTextureShare() {
function syncOpenRgb() {
const o = (currentConfig && currentConfig.openrgb) || {};
- if (!o.enabled) return openrgb.stop().then(() => openrgb.status());
+ // Otomasyonda OpenRGB sunucusuna bağlanılmıyor (bkz. HW_OFF)
+ if (!o.enabled || HW_OFF) return openrgb.stop().then(() => openrgb.status());
return openrgb.start(o).then((st) => {
notifyAdmin('openrgb-status', st);
return st;
@@ -2108,7 +2134,7 @@ ipcMain.handle('artnet:status', () => artnet.status());
ipcMain.handle('artnet:sync', () => syncArtnet());
ipcMain.handle('openrgb:status', () => openrgb.status());
ipcMain.handle('openrgb:sync', () => syncOpenRgb());
-ipcMain.handle('openrgb:rescan', () => openrgb.rescan());
+ipcMain.handle('openrgb:rescan', () => (HW_OFF ? openrgb.status() : openrgb.rescan()));
ipcMain.handle('texture:status', () => textureShare.status());
ipcMain.handle('texture:sync', () => syncTextureShare());
ipcMain.handle('texture:senders', () => textureShare.listSenders());
@@ -2289,6 +2315,223 @@ ipcMain.handle('milkdrop:library-import', async (e, token) => {
return r;
});
+// ----------------------------------------------------------------------------
+// MilkDrop küçük resimleri (#575)
+//
+// Panel görünen hücrelerin kimliklerini istiyor. Burada her birinin
+// ANAHTARI hesaplanıyor (reçete + kaynak + istediği dokular); önbellekte
+// olan hemen dönüyor, olmayan kuyruğa giriyor ve gizli bir pencerede tek
+// tek çiziliyor. Biten `milkdrop:thumb` ile sayfaya bildiriliyor. Pencere
+// boşta kalınca kapanıyor. Ayrıntı: src/main/milkdrop-thumbs.js.
+// ----------------------------------------------------------------------------
+const mdThumbs = require('./milkdrop-thumbs');
+const THUMB_IDLE_MS = 20000;
+const THUMB_JOB_MS = 30000;
+let thumbRecipe = '';
+let thumbQ = null;
+let thumbWin = null;
+let thumbWinReady = null;
+let thumbJob = null; // { key, resolve, timer }
+let thumbIdle = null;
+let thumbClient = null; // son isteyen sayfa
+let thumbPruned = false;
+let mdBuiltins = null;
+
+/* Öz test gerçek kullanıcı verisiyle koşuyor: küçük resimler onun yerine
+ geçici bir klasöre (öz testin sonunda siliniyor). */
+let smokeThumbDir = '';
+function thumbDir() {
+ if (SMOKE) {
+ if (!smokeThumbDir) smokeThumbDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'sv-smoke-thumbs-'));
+ return smokeThumbDir;
+ }
+ return path.join(app.getPath('userData'), 'milkdrop-thumbs');
+}
+
+function milkdropBuiltins() {
+ if (!mdBuiltins) {
+ try { mdBuiltins = require('../shared/presets-milkdrop.js'); } catch { mdBuiltins = []; }
+ if (!Array.isArray(mdBuiltins)) mdBuiltins = [];
+ }
+ return mdBuiltins;
+}
+
+function thumbSourceOf(id) {
+ const b = milkdropBuiltins().find((p) => p.id === id);
+ if (b) return b.source;
+ const p = presetsStore.get(id);
+ return p && p.kind === 'milkdrop' && typeof p.source === 'string' ? p.source : null;
+}
+
+function thumbRecipeHash() {
+ if (!thumbRecipe) thumbRecipe = mdThumbs.recipeHash(path.join(__dirname, '..', '..'));
+ return thumbRecipe;
+}
+
+/* İstek başına bir kez: motorun göreceği sıralı doku listesi ve her
+ dosyanın damgası (boyut + değişme zamanı). */
+function thumbTextureLib() {
+ const dirs = textureDirs();
+ const names = mdTex.listTexturesIn(dirs).names;
+ const stamps = new Map();
+ return {
+ names,
+ stamp(n) {
+ if (!stamps.has(n)) {
+ let s = '-';
+ const t = mdTex.textureFileInfoIn(dirs, n, TEX_MAX_BYTES);
+ if (t) {
+ try { const st = fs.statSync(t.file); s = st.size + ':' + Math.round(st.mtimeMs); } catch { s = '-'; }
+ }
+ stamps.set(n, s);
+ }
+ return stamps.get(n);
+ },
+ };
+}
+
+function thumbKeyOf(source, lib) {
+ return mdThumbs.keyOf(thumbRecipeHash(), source, mdThumbs.textureSig(source, lib));
+}
+
+function ensureThumbWin() {
+ if (thumbWin && !thumbWin.isDestroyed()) return thumbWinReady;
+ const win = new BrowserWindow({
+ width: 320,
+ height: 180,
+ show: false,
+ skipTaskbar: true,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload-thumbs.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ backgroundThrottling: false,
+ },
+ });
+ thumbWin = win;
+ attachSmoke(win, 'THUMBS');
+ thumbWinReady = new Promise((resolve) => {
+ const onReady = (e) => {
+ if (e.sender !== win.webContents) return;
+ ipcMain.removeListener('thumbs:ready', onReady);
+ resolve(true);
+ };
+ ipcMain.on('thumbs:ready', onReady);
+ win.on('closed', () => { ipcMain.removeListener('thumbs:ready', onReady); resolve(false); });
+ });
+ win.on('closed', () => {
+ if (thumbWin === win) { thumbWin = null; thumbWinReady = null; }
+ const j = thumbJob;
+ if (j) { thumbJob = null; clearTimeout(j.timer); j.resolve(null); }
+ });
+ /* Çizim süreci çöktüyse pencere kapanmıyor, `closed` gelmiyor: süren iş
+ 30 sn bekler, sonraki her hücre de. Pencere yok ediliyor; süren iş
+ başarısız, sıradaki yeni bir pencerede. */
+ win.webContents.on('render-process-gone', () => {
+ try { win.destroy(); } catch { /* zaten kapalı */ }
+ });
+ win.loadFile(path.join(__dirname, '..', 'thumbs', 'index.html')).catch(() => {
+ try { win.destroy(); } catch { /* zaten kapalı */ }
+ });
+ return thumbWinReady;
+}
+
+/* Gizli pencere (bkz. closeHelperWindows): panel kapanınca bekleyen işler
+ bırakılıyor ve pencere hemen kapanıyor. */
+function closeThumbWin() {
+ clearTimeout(thumbIdle);
+ if (thumbQ) thumbQ.clear();
+ if (thumbWin && !thumbWin.isDestroyed()) { try { thumbWin.destroy(); } catch { /* zaten kapalı */ } }
+}
+
+function closeThumbWinSoon() {
+ clearTimeout(thumbIdle);
+ thumbIdle = setTimeout(() => {
+ if (thumbQ && thumbQ.pending()) return;
+ if (thumbWin && !thumbWin.isDestroyed()) { try { thumbWin.destroy(); } catch { /* zaten kapalı */ } }
+ }, THUMB_IDLE_MS);
+}
+
+ipcMain.on('thumbs:done', (e, key, r) => {
+ const j = thumbJob;
+ if (!j || !thumbWin || thumbWin.isDestroyed() || e.sender !== thumbWin.webContents || j.key !== key) return;
+ thumbJob = null;
+ clearTimeout(j.timer);
+ j.resolve(r);
+});
+
+async function renderThumb(job) {
+ clearTimeout(thumbIdle);
+ /* Kuyrukta beklerken dokular değiştiyse bu anahtarın görüntüsü artık bu
+ değil: çizilmiyor, bekleyenlere yeniden istemeleri söyleniyor. */
+ if (thumbKeyOf(job.source, thumbTextureLib()) !== job.key) return { stale: true };
+ const ready = await ensureThumbWin();
+ if (!ready || !thumbWin || thumbWin.isDestroyed()) return null;
+ const cfg = currentConfig || {};
+ const md = cfg.milkdrop || {};
+ const lib = cfg.milkdropLibrary || {};
+ const r = await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ if (thumbJob && thumbJob.key === job.key) { thumbJob = null; resolve(null); }
+ }, THUMB_JOB_MS);
+ thumbJob = { key: job.key, resolve, timer };
+ thumbWin.webContents.send('thumbs:job', {
+ key: job.key, source: job.source,
+ textureDir: md.textureDir || '', textureRev: +lib.textureRev || 0,
+ recipe: mdThumbs.RECIPE,
+ });
+ });
+ closeThumbWinSoon();
+ return r && r.ok && r.bytes ? Buffer.from(r.bytes) : null;
+}
+
+function thumbQueue() {
+ if (!thumbQ) {
+ thumbQ = new mdThumbs.ThumbQueue({
+ dir: thumbDir(),
+ render: renderThumb,
+ onReady: (id, key, ok, stale) => {
+ if (thumbClient && !thumbClient.isDestroyed()) thumbClient.send('milkdrop:thumb', { id, key, ok, stale: !!stale });
+ },
+ });
+ }
+ return thumbQ;
+}
+
+/* Oturum başına bir kez, arka planda: bugünkü presetlerin anahtarı olmayan
+ küçük resimler siliniyor. Anahtarlar parça parça hesaplanıyor; ana süreç
+ sesi, ışıkları ve IPC'yi beklemeden sürdürsün. */
+async function pruneThumbs() {
+ const started = Date.now();
+ await presetsStore.warm();
+ const lib = thumbTextureLib();
+ const live = new Set();
+ const all = milkdropBuiltins().concat(presetsStore.list().filter((p) => p && p.kind === 'milkdrop'));
+ for (let i = 0; i < all.length; i++) {
+ if (typeof all[i].source === 'string') live.add(thumbKeyOf(all[i].source, lib));
+ if (i % 250 === 249) await new Promise((r) => setImmediate(r));
+ }
+ mdThumbs.prune(thumbDir(), live, started);
+}
+
+ipcMain.handle('milkdrop:thumbs', async (e, ids) => {
+ await presetsStore.warm();
+ const list = Array.isArray(ids) ? ids.filter((x) => typeof x === 'string').slice(0, 200) : [];
+ thumbClient = e.sender;
+ const lib = thumbTextureLib();
+ const items = [];
+ for (const id of list) {
+ const source = thumbSourceOf(id);
+ if (typeof source === 'string' && source) items.push({ id, source, key: thumbKeyOf(source, lib) });
+ }
+ const ready = thumbQueue().request(items);
+ if (!thumbPruned) {
+ thumbPruned = true;
+ setTimeout(() => { pruneThumbs().catch(() => {}); }, 5000);
+ }
+ return { ok: true, ready };
+});
+
// ----------------------------------------------------------------------------
// MilkDrop sprite'ları (#577): milk_img.ini
//
@@ -2843,7 +3086,7 @@ app.whenReady().then(async () => {
currentConfig = null;
}
if (currentConfig?.lighting?.enabled) {
- dynamicLighting.setConfig(currentConfig.lighting).catch(() => {});
+ lightingSet(currentConfig.lighting).catch(() => {});
}
syncNowPlaying();
/* Preset deposu arka planda okunmaya başlıyor (#574): pencereler açılıp
@@ -2883,6 +3126,23 @@ app.whenReady().then(async () => {
}
});
+ /* MilkDrop küçük resimleri (#575). Adres bir yol değil, içeriğin
+ anahtarı: dosya yalnız küçük resim klasöründen okunuyor. Anahtar
+ içerikten türediği için değişmiyor, önbellekte süresiz kalabilir. */
+ protocol.handle('sv-thumb', async (request) => {
+ const file = mdThumbs.fileForUrl(thumbDir(), request.url);
+ if (!file) return new Response('not found', { status: 404 });
+ try {
+ const buf = await fs.promises.readFile(file);
+ return new Response(new Uint8Array(buf), {
+ status: 200,
+ headers: { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=31536000, immutable' },
+ });
+ } catch {
+ return new Response('not found', { status: 404 });
+ }
+ });
+
/* Kamera izni yalnızca kendi pencerelerimize verilir.
Otomasyon kiplerinde (--smoke, --shots) KAMERA HİÇ AÇILMAZ. Kaydedilmiş
@@ -3276,6 +3536,46 @@ async function runSmoke() {
}
}
+ /* --- MilkDrop küçük resmi (#575) ---
+ Zincirin tamamı yalnız gerçek uygulamada görünüyor: reçetenin özeti
+ (paketlenmiş uygulamada asar'ın içindeki dosyalar), kuyruk, gizli
+ pencere, `sv-thumb:` protokolü ve panelin CSP'si. Yerleşik bir preset
+ çiziliyor — öz testte küçük resim klasörü geçici bir klasör, kullanıcının
+ verisine yazılmıyor — ve panelde bir ile geri okunuyor. */
+ const thumbProbe = async () => {
+ try {
+ await presetsStore.warm();
+ const b = milkdropBuiltins()[0];
+ if (!b) return { hata: 'yerleşik preset yok' };
+ const key = thumbKeyOf(b.source, thumbTextureLib());
+ const file = path.join(thumbDir(), key + '.webp');
+ const t0 = Date.now();
+ thumbQueue().request([{ id: b.id, key, source: b.source }]);
+ for (let i = 0; i < 120 && !fs.existsSync(file); i++) await wait(250);
+ const ms = Date.now() - t0;
+ if (!fs.existsSync(file)) return { hata: 'dosya yazılmadı', ms };
+ const head = fs.readFileSync(file).subarray(0, 12).toString('latin1');
+ const img = adminWin && !adminWin.isDestroyed() ? await adminWin.webContents.executeJavaScript(`new Promise(function (done) {
+ var i = new Image();
+ i.onload = function () { done({ w: i.naturalWidth, h: i.naturalHeight }); };
+ i.onerror = function () { done({ hata: 'yüklenmedi' }); };
+ i.src = ${JSON.stringify('sv-thumb://t/' + key + '.webp')};
+ })`) : { hata: 'panel yok' };
+ return { ms, bytes: fs.statSync(file).size, webp: head.slice(0, 4) === 'RIFF' && head.slice(8, 12) === 'WEBP', img };
+ } catch (e) {
+ return { hata: String((e && e.message) || e) };
+ }
+ };
+ const th = await thumbProbe();
+ console.log('[SMOKE] küçük resim: ' + JSON.stringify(th));
+ if (th.hata) errors.push('thumbnail: ' + th.hata);
+ else {
+ if (!th.webp) errors.push('thumbnail: the file is not a WebP');
+ if (!th.img || th.img.w !== mdThumbs.RECIPE.thumbW || th.img.h !== mdThumbs.RECIPE.thumbH) {
+ errors.push('thumbnail: the panel could not show it (' + JSON.stringify(th.img) + ')');
+ }
+ }
+
/* --- Rasterizer denemesi (--smoke-raster) ---
Aynı 2D arkaplanı iki tuvale çizer: biri willReadFrequently (yazılım
rasterizer), diğeri normal (GPU). Piksel farkı, katman refactoru
@@ -5012,6 +5312,9 @@ async function runSmoke() {
if (monoFrames > 0) {
errors.push('audio: ' + monoFrames + ' helper frames arrived without the left and right channels');
}
+ // Öz testin küçük resim klasörü geçici (#575)
+ closeThumbWin();
+ if (smokeThumbDir) { try { fs.rmSync(smokeThumbDir, { recursive: true, force: true }); } catch { /* geçici */ } }
if (errors.length) {
console.log('[SMOKE] RESULT: FAIL (' + errors.length + ' error)');
errors.slice(0, 20).forEach((m) => console.log('[SMOKE] ! ' + m));
diff --git a/src/main/milkdrop-import.js b/src/main/milkdrop-import.js
index 1bc092f..20adeec 100644
--- a/src/main/milkdrop-import.js
+++ b/src/main/milkdrop-import.js
@@ -37,8 +37,6 @@ const MAX_FILES = 200000;
const MAX_DEPTH = 12;
const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
const TEX_DIRS = ['textures', 'sprites'];
-/* Motorun kendi örnekleyicileri: preset bunları istese de dosya değil. */
-const BUILTIN_SAMPLERS = /^(main|fw_main|fc_main|pw_main|pc_main|noise_[lmh]q|noisevol_[lh]q|blur[123]|rand\d\d)(_|$)/i;
const lower = (s) => String(s || '').toLowerCase();
const extOf = (n) => path.extname(String(n || '')).toLowerCase();
@@ -236,16 +234,13 @@ function decodeText(buf) {
const SEP = String.fromCharCode(0);
const keyOf = (name, source) => name + SEP + crypto.createHash('sha1').update(source).digest('hex');
-/* Presetin istediği dosya dokuları: `sampler_worms` → "worms". */
+/* Presetin istediği dosya dokuları: `sampler_worms` → "worms". Süzme/sarma
+ ön eki soyuluyor (`sampler_fw_worms` de "worms"): önceden soyulmuyordu ve
+ ön ekli isteyen bir presetin yanındaki doku içe aktarımda kopyalanmıyordu
+ (#575'te bulundu). Motorun kendi örnekleyicileri ve `randNN` yuvaları dosya
+ adı değil (bkz. `milkdrop-textures.js` `userSamplers`). */
function samplerNames(source) {
- const out = new Set();
- const re = /\bsampler_([A-Za-z0-9_]+)/g;
- let m;
- while ((m = re.exec(source))) {
- const n = m[1];
- if (!BUILTIN_SAMPLERS.test(n)) out.add(lower(n));
- }
- return out;
+ return tex.userSamplers(source).names;
}
/* ÇALIŞTIRMA. `deps`:
diff --git a/src/main/milkdrop-textures.js b/src/main/milkdrop-textures.js
index afc886f..c4004b8 100644
--- a/src/main/milkdrop-textures.js
+++ b/src/main/milkdrop-textures.js
@@ -90,7 +90,13 @@ function textureFileInfo(dir, name, maxBytes) {
/* BİRDEN ÇOK KLASÖR (#574). Kullanıcının seçtiği doku klasörü ve içe
aktarılan paketlerin dokularının kopyalandığı, uygulamaya ait klasör.
Aynı ad iki yerde varsa kullanıcınınki geçerli: sırayla bakılıyor.
- Adlar birleşiyor, her ad bir kez. */
+ Adlar birleşiyor, her ad bir kez.
+
+ Liste SIRALI (#575). `sampler_rand00` gibi yuvalar dokuyu bu listedeki
+ SIRASINDAN seçiyor; dosya sisteminin sırası Windows'ta alfabetik,
+ Linux'ta rastgele. Sıralayınca aynı preset her makinede aynı dokuyu
+ alıyor, küçük resmi de her seferinde aynı çıkıyor. Sıralama ölçüm
+ betiğininkiyle aynı (`milkdrop-render-rate.js`, varsayılan `sort`). */
function listTexturesIn(dirs) {
const names = [];
const seen = new Set();
@@ -103,9 +109,35 @@ function listTexturesIn(dirs) {
names.push(n);
}
}
+ names.sort();
return { dir: (Array.isArray(dirs) && dirs[0]) || '', names };
}
+/* PRESETİN İSTEDİĞİ KULLANICI DOKULARI (#574, #575). Çevirici
+ (`milkdrop-shader.js`, `canonSampler`) süzme/sarma ön ekini soyuyor:
+ `sampler_fw_worms` de `sampler_pc_worms` de `worms` dosyasını istiyor.
+ Motorun kendi örnekleyicileri (main, blur, gürültü) dosya değil.
+ `rand00` … `rand15` klasörden SIRAYLA seçiliyor: ada değil bütün listeye
+ bağlı (`rand`). Adlar küçük harf, uzantısız — motor da öyle eşliyor
+ (`_texFileFor`). */
+const SAMPLER_PREFIX = /^(fw|pw|fc|pc)_/i;
+const ENGINE_SAMPLERS = /^(main|blur[123]|noise_lq|noise_lq_lite|noise_mq|noise_hq|noisevol_lq|noisevol_hq)$/i;
+
+function userSamplers(source) {
+ const names = new Set();
+ let rand = false;
+ const re = /\bsampler_([A-Za-z0-9_]+)/g;
+ const text = String(source || '');
+ let m;
+ while ((m = re.exec(text))) {
+ const base = m[1].replace(SAMPLER_PREFIX, '').toLowerCase();
+ if (!base || ENGINE_SAMPLERS.test(base)) continue;
+ if (/^rand\d\d(_|$)/.test(base)) { rand = true; continue; }
+ names.add(base);
+ }
+ return { names, rand };
+}
+
function textureFileInfoIn(dirs, name, maxBytes) {
for (const d of Array.isArray(dirs) ? dirs : []) {
if (!d) continue;
@@ -117,5 +149,5 @@ function textureFileInfoIn(dirs, name, maxBytes) {
module.exports = {
TEXTURE_EXT, TEXTURE_MIME, isTextureFile, mimeFor, resolveTexture, listTextures, textureFileInfo,
- listTexturesIn, textureFileInfoIn,
+ listTexturesIn, textureFileInfoIn, userSamplers,
};
diff --git a/src/main/milkdrop-thumbs.js b/src/main/milkdrop-thumbs.js
new file mode 100644
index 0000000..224a936
--- /dev/null
+++ b/src/main/milkdrop-thumbs.js
@@ -0,0 +1,240 @@
+'use strict';
+/* MILKDROP KÜÇÜK RESİMLERİ (#575).
+
+ Liste yalnız adlardı; yüzlerce presetin içinden birini görünüşüyle bulmak
+ olanaksızdı. Her preset bir kez, küçük, gizli bir pencerede çiziliyor ve
+ kullanıcı verisinde saklanıyor (`milkdrop-thumbs/.webp`). Bu
+ modül Electron'a bağlı değil: çizici bir parametre, testler gerçek
+ klasörlerle koşuyor.
+
+ REÇETE (ölçüldü, korpustan tohumlu 200 preset):
+ - SİYAH TAMPONDAN başlanıyor. Sabit bir tohum görüntüsüyle başlamak
+ (yerleşik bir preset 45 kare, sonra sert geçiş) siyah küçük resimleri
+ yalnız 20'den 18'e indirdi — dördünü düzeltip ikisini bozarak — ve işi
+ %45 uzattı; daha kötüsü, tohumun renkleri kendi başına bambaşka çizen
+ presetlerin küçük resmine giriyordu. Küçük resim presetin KENDİ
+ ürettiğini göstermeli.
+ - 60 KARE, kare başı 1/30 sn (örnek sesin iki saniyesi). 90 kare siyahı
+ 18'den 20'ye, 150 kare 22'ye çıkardı (bazı presetler zamanla sönüyor);
+ süre 259 → 350 → 538 ms (ortanca).
+ - 640x360 ÇİZİLİP 256x144'e küçültülüyor. 320x180'e göre süre neredeyse
+ aynı (ortanca 250 ms / 259 ms: iş kare başına JS'te, pikselde değil),
+ ama genişliğe bağlı ayrıntılar (ince dalgalar, şekiller) gerçek
+ boyuttaki gibi. WebP ~3,6 KB.
+ Her iş YENİ bir motor örneğinde (bağlamı `dispose` bırakıyor): önceki
+ işten hiçbir durum taşınmıyor, aynı preset hangi presetten sonra çizilirse
+ çizilsin aynı baytlar (ölçüldü: 8/8).
+
+ ANAHTAR içeriğe bağlı: reçetenin özeti + presetin kaynağı + istediği
+ dokuların imzası. Reçetenin özeti çizim sayfasının yüklediği DOSYALARIN
+ baytlarından: motor değişince (#580, #567 gibi) eski küçük resimler
+ kendiliğinden geçersiz — elle artırılan bir sürüm numarası unutulurdu.
+ Preset değişince anahtarı değişiyor ve yeniden çiziliyor; aynı içerikteki
+ iki preset tek küçük resmi paylaşıyor. */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const tex = require('./milkdrop-textures');
+
+const RECIPE = Object.freeze({
+ renderW: 640,
+ renderH: 360,
+ frames: 60,
+ fps: 30,
+ thumbW: 256,
+ thumbH: 144,
+ quality: 0.8,
+ // Doku bu sürede gelmezse iş başarısız: görüntü diskin hızına kalmasın
+ textureWaitMs: 8000,
+});
+
+/* Çizim sayfasının yüklediği dosyalar, sırasıyla. `src/thumbs/index.html`
+ aynı betikleri aynı sırayla yüklüyor (test eşitliği denetliyor). */
+const PAGE_FILES = Object.freeze([
+ 'src/thumbs/index.html',
+ 'src/shared/milkdrop.js',
+ 'src/shared/milkdrop-audio.js',
+ 'src/shared/milkdrop-hlsl.js',
+ 'src/shared/milkdrop-shader.js',
+ 'src/visualizer/modes/milkdrop.js',
+ 'src/shared/demo-audio.js',
+ 'src/thumbs/thumbs.js',
+]);
+
+const KEY_RE = /^[0-9a-f]{40}$/;
+const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
+
+/* Reçetenin özeti: sabitler + sayfanın dosyaları. Paketlenmiş uygulamada
+ dosyalar asar'ın içinde; Electron'un `fs`i onları da okuyor. */
+function recipeHash(root) {
+ const h = crypto.createHash('sha1');
+ h.update(JSON.stringify(RECIPE));
+ for (const rel of PAGE_FILES) {
+ h.update('\n' + rel + '\n');
+ h.update(fs.readFileSync(path.join(root, rel)));
+ }
+ return h.digest('hex');
+}
+
+/* Presetin görüntüsünü etkileyen dokuların imzası. `lib`: istek başına bir
+ kez hazırlanan { names (sıralı, motorun gördüğü liste), stamp(ad) →
+ "boyut:değişme" ya da "-" }. Adla istenen doku motordaki gibi çözülüyor
+ (uzantısız, büyük/küçük harf ayırmadan, listedeki İLK eşleşen);
+ `randNN` yuvası bütün listeden seçtiği için onu kullanan presetin imzası
+ bütün listeyi taşıyor. Doku istemeyen presetin imzası boş. */
+function textureSig(source, lib) {
+ const want = tex.userSamplers(source);
+ const names = (lib && Array.isArray(lib.names)) ? lib.names : [];
+ const stamp = (n) => (lib && typeof lib.stamp === 'function' ? lib.stamp(n) : '-');
+ const stemOf = (f) => { const d = f.lastIndexOf('.'); return (d < 0 ? f : f.slice(0, d)).toLowerCase(); };
+ const parts = [];
+ for (const base of Array.from(want.names).sort()) {
+ const f = names.find((x) => stemOf(x) === base) || '';
+ parts.push(base + '=' + (f ? f + '@' + stamp(f) : '-'));
+ }
+ if (want.rand) parts.push('*=' + names.map((f) => f + '@' + stamp(f)).join(','));
+ return parts.join(';');
+}
+
+function keyOf(recipe, source, texSig) {
+ return sha1(String(recipe || '') + '\n' + String(texSig || '') + '\n' + String(source || ''));
+}
+
+const fileOf = (dir, key) => path.join(dir, key + '.webp');
+
+/* Yarım dosya kalmasın: önce geçici ad, sonra yeniden adlandırma. */
+function writeAtomic(file, buf) {
+ const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(tmp, buf);
+ try {
+ fs.renameSync(tmp, file);
+ } catch (e) {
+ try { fs.unlinkSync(tmp); } catch { /* yoksa zaten yok */ }
+ throw e;
+ }
+}
+
+/* `sv-thumb://t/.webp` → dosya. Yalnız 40 haneli onaltılık bir
+ anahtar ve yalnız bu klasör: adres bir yol değil, sayfaya dosya sistemi
+ açmıyor. */
+function fileForUrl(dir, url) {
+ let u;
+ try { u = new URL(String(url || '')); } catch { return null; }
+ if (u.protocol !== 'sv-thumb:') return null;
+ const m = /^\/([0-9a-f]{40})\.webp$/.exec(u.pathname || '');
+ if (!m) return null;
+ return fileOf(dir, m[1]);
+}
+
+/* KUYRUK. Bir seferde tek iş; en son istenen parti öne geçiyor (panelde
+ görünen hücreler önce), aynı anahtar bir kez çiziliyor ve bekleyen her
+ kimliğe bildiriliyor. Kuyruk sınırı aşılınca en eski istekler düşüyor
+ (panel görünenleri yeniden istiyor). Başarısız anahtar bu oturumda bir
+ daha denenmiyor.
+ render(job) → Promise
+ `stale`: iş beklerken anahtarı eskidi (ör. dokular değişti) — başarısız
+ sayılmıyor, bekleyenlere "yeniden iste" deniyor.
+ onReady(id, key, ok, stale) */
+class ThumbQueue {
+ constructor(opts) {
+ const o = opts || {};
+ this.dir = o.dir;
+ this.render = o.render;
+ this.onReady = typeof o.onReady === 'function' ? o.onReady : () => {};
+ this.max = o.max || 800;
+ this.queue = [];
+ this.byKey = new Map();
+ this.failed = new Set();
+ this.running = null;
+ this.rendered = 0;
+ }
+
+ file(key) { return fileOf(this.dir, key); }
+
+ has(key) {
+ try { return fs.statSync(this.file(key)).isFile(); } catch { return false; }
+ }
+
+ /* items: [{ id, key, source }]. Dönüş: hazır olanlar { id: anahtar } —
+ başarısızlar '' ile. Gerisi `onReady`le gelecek. */
+ request(items) {
+ const ready = {};
+ const batch = [];
+ const inBatch = new Set();
+ for (const it of Array.isArray(items) ? items : []) {
+ if (!it || typeof it.id !== 'string' || !KEY_RE.test(String(it.key || ''))) continue;
+ if (this.has(it.key)) { ready[it.id] = it.key; continue; }
+ if (this.failed.has(it.key)) { ready[it.id] = ''; continue; }
+ let job = this.byKey.get(it.key);
+ if (!job) {
+ job = { key: it.key, source: String(it.source || ''), ids: new Set() };
+ this.byKey.set(it.key, job);
+ }
+ job.ids.add(it.id);
+ if (job !== this.running && !inBatch.has(job)) { inBatch.add(job); batch.push(job); }
+ }
+ this.queue = batch.concat(this.queue.filter((j) => !inBatch.has(j)));
+ while (this.queue.length > this.max) this.byKey.delete(this.queue.pop().key);
+ this._pump();
+ return ready;
+ }
+
+ pending() { return this.queue.length + (this.running ? 1 : 0); }
+
+ // Bekleyen işler bırakılıyor (panel kapandı); süren iş kendi sonucunu alıyor
+ clear() {
+ for (const j of this.queue) this.byKey.delete(j.key);
+ this.queue = [];
+ }
+
+ async _pump() {
+ if (this.running || !this.queue.length) return;
+ const job = this.queue.shift();
+ this.running = job;
+ let res = null;
+ try { res = await this.render(job); } catch { res = null; }
+ const stale = !!(res && res.stale === true);
+ let ok = false;
+ if (!stale && res && res.length) {
+ try { writeAtomic(this.file(job.key), res); ok = true; } catch { ok = false; }
+ }
+ if (ok) this.rendered++;
+ else if (!stale) this.failed.add(job.key);
+ this.byKey.delete(job.key);
+ this.running = null;
+ for (const id of job.ids) {
+ try { this.onReady(id, ok ? job.key : '', ok, stale); } catch { /* dinleyici hatası kuyruğu durdurmasın */ }
+ }
+ this._pump();
+ }
+}
+
+/* ARTIK TEMİZLİĞİ. Bugünkü hiçbir presetin anahtarı olmayan dosyalar
+ (silinmiş ya da değişmiş presetler, eski reçete) ve yarım kalmış geçici
+ dosyalar siliniyor. Oturum başına bir kez, arka planda. `before`: yalnız
+ bu andan önce yazılmış dosyalar — anahtarlar hesaplanırken eklenen bir
+ presetin yeni küçük resmi listede yok ama artık değil. */
+function prune(dir, liveKeys, before) {
+ let names;
+ try { names = fs.readdirSync(dir); } catch { return { removed: 0, kept: 0 }; }
+ const limit = typeof before === 'number' ? before : Infinity;
+ let removed = 0;
+ let kept = 0;
+ for (const n of names) {
+ const m = /^([0-9a-f]{40})\.webp$/.exec(n);
+ if (m && liveKeys.has(m[1])) { kept++; continue; }
+ if (!m && !/\.tmp$/.test(n)) continue; // bizim olmayan dosyaya dokunulmuyor
+ const file = path.join(dir, n);
+ try {
+ if (fs.statSync(file).mtimeMs >= limit) { kept++; continue; }
+ fs.unlinkSync(file);
+ removed++;
+ } catch { /* kilitli ya da gitmiş: sonraki oturum */ }
+ }
+ return { removed, kept };
+}
+
+module.exports = {
+ RECIPE, PAGE_FILES, KEY_RE, recipeHash, textureSig, keyOf, writeAtomic, fileForUrl, ThumbQueue, prune,
+};
diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js
index 6de8d02..6e95a58 100644
--- a/src/main/preload-admin.js
+++ b/src/main/preload-admin.js
@@ -117,6 +117,11 @@ contextBridge.exposeInMainWorld('api', {
rescanMilkdropLibrary: (token) => ipcRenderer.invoke('milkdrop:library-rescan', token),
importMilkdropLibrary: (token) => ipcRenderer.invoke('milkdrop:library-import', token),
onMilkdropLibraryProgress: (cb) => ipcRenderer.on('milkdrop:library-progress', (e, p) => cb(p)),
+ /* Küçük resimler (#575): görünen kimlikler isteniyor; hazır olanların
+ anahtarı hemen, gerisi çizildikçe `milkdrop:thumb` ile. Görsel
+ `sv-thumb://t/.webp` adresinden. */
+ milkdropThumbs: (ids) => ipcRenderer.invoke('milkdrop:thumbs', ids),
+ onMilkdropThumb: (cb) => ipcRenderer.on('milkdrop:thumb', (e, t) => cb(t)),
// Yayın çıkışı (OBS tarayıcı kaynağı + mobil kumanda)
streamStatus: () => ipcRenderer.invoke('stream:status'),
diff --git a/src/main/preload-thumbs.js b/src/main/preload-thumbs.js
new file mode 100644
index 0000000..5df8783
--- /dev/null
+++ b/src/main/preload-thumbs.js
@@ -0,0 +1,16 @@
+'use strict';
+/* MilkDrop küçük resim penceresinin köprüsü (#575). Motorun doku istekleri
+ görselleştiriciyle aynı kanallardan (aynı iki klasör, aynı denetim); iş
+ alıp sonucu döndürmek için ayrı bir köprü. */
+const { contextBridge, ipcRenderer } = require('electron');
+
+contextBridge.exposeInMainWorld('api', {
+ milkdropTextures: () => ipcRenderer.invoke('milkdrop:textures'),
+ milkdropTexture: (name) => ipcRenderer.invoke('milkdrop:texture', name),
+});
+
+contextBridge.exposeInMainWorld('thumbs', {
+ onJob: (cb) => ipcRenderer.on('thumbs:job', (e, job) => cb(job)),
+ done: (key, result) => ipcRenderer.send('thumbs:done', key, result),
+ ready: () => ipcRenderer.send('thumbs:ready'),
+});
diff --git a/src/shared/i18n.js b/src/shared/i18n.js
index 4ac2d61..2c2e4f8 100644
--- a/src/shared/i18n.js
+++ b/src/shared/i18n.js
@@ -1719,6 +1719,14 @@
'Arama süre sınırına ulaştı, bazı klasörlere bakılamadı. Kütüphaneniz listede yoksa 📁 Klasörden İçe Aktar ile seçin.':
'The search reached its time limit and some folders were not checked. If your library is not listed, choose it with 📁 Import from Folder.',
'Presetlerin yanındaki {n} görselden yalnız presetlerin istediği kopyalanır.': 'Of the {n} images next to the presets, only the ones the presets ask for are copied.',
+ // ---- Küçük resimler (#575) ----
+ 'Düzen': 'Layout',
+ 'Liste': 'List',
+ // 'Izgara' → 'Grid' aşağıda arkaplan adlarıyla zaten var
+ 'Küçük resim hazırlanıyor…': 'Preparing thumbnail…',
+ 'Küçük resim çizilemedi': 'The thumbnail could not be drawn',
+ 'Küçük resim, presetin ilk iki saniyesi: örnek sesle, siyah bir ekrandan başlanarak çiziliyor. Her preset bir kez çiziliyor ve saklanıyor; preset değişince yeniden çiziliyor.':
+ 'A thumbnail shows the first two seconds of a preset, drawn with the demo sound from a black screen. Each preset is drawn once and kept, and drawn again when it changes.',
'ZIP paketi, bir klasör ya da makinede bulunan bir kütüphane: önce ne ekleneceği gösterilir, onaylamadan hiçbir şey kopyalanmaz. İç içe klasörler ve dokular dahil; aynı ad ve içerikteki presetler atlanır. Dokular uygulamanın kendi klasörüne gider ve seçtiğiniz doku klasöründen sonra aranır. Uygulamayla hiçbir preset paketi gelmez. .milk2 (MilkDrop 3 çift preseti) henüz desteklenmiyor.':
'A ZIP pack, a folder or a library found on this computer: what will be added is shown first, and nothing is copied until you confirm. Nested folders and textures are included; presets with the same name and content are skipped. Textures go into the app’s own folder and are looked up after the texture folder you chose. No preset pack ships with the app. .milk2 (MilkDrop 3 double presets) is not supported yet.',
'Sil': 'Delete',
diff --git a/src/shared/presets-milkdrop.js b/src/shared/presets-milkdrop.js
index e0ee19f..49b7f99 100644
--- a/src/shared/presets-milkdrop.js
+++ b/src/shared/presets-milkdrop.js
@@ -13,16 +13,12 @@
Kimlikler koddan geliyor ve SABİT: kullanıcı deposundaki `md_`
ile çakışmasınlar diye `md_caya_` önekli, ve `safeName`in izin verdiği
- karakter kümesinde. */
-(function () {
- const P = typeof window !== 'undefined' ? window.SVPresets : null;
- if (!P || !P.registerBuiltin) return;
- /* Aynı sayfada iki kez değerlendirilirse `registerBuiltin` yinelenen
- kimlik için hata atıyor ve kayıt yarıda kalıyor. Nöbetçi, ikinci
- değerlendirmeyi sessizce geçiyor. */
- if (window.__SVMilkdropBuiltins) return;
- window.__SVMilkdropBuiltins = true;
+ karakter kümesinde.
+ Liste ÖNCE kuruluyor ve Node'da da dışa veriliyor (#575): ana süreç
+ küçük resim anahtarını ve artık temizliğini yerleşiklerin kaynağından
+ hesaplıyor. Kayıt yalnız sayfada, `SVPresets` varken. */
+(function () {
const MD = (id, name, description, source) => ({
id, name, description, source,
kind: 'milkdrop', engine: 'milkdrop', tags: ['milkdrop'],
@@ -780,7 +776,14 @@ wave_1_per_point2=y = 0.12 + 0.62*value1;
wave_1_per_point3=a = 0.14 + 0.26*value1;`),
];
- P.registerBuiltin(LIST);
if (typeof module !== 'undefined' && module.exports) module.exports = LIST;
+ const P = typeof window !== 'undefined' ? window.SVPresets : null;
+ if (!P || !P.registerBuiltin) return;
+ /* Aynı sayfada iki kez değerlendirilirse `registerBuiltin` yinelenen
+ kimlik için hata atıyor ve kayıt yarıda kalıyor. Nöbetçi, ikinci
+ değerlendirmeyi sessizce geçiyor. */
+ if (window.__SVMilkdropBuiltins) return;
+ window.__SVMilkdropBuiltins = true;
+ P.registerBuiltin(LIST);
window.SVMilkdropBuiltins = LIST;
})();
diff --git a/src/thumbs/index.html b/src/thumbs/index.html
new file mode 100644
index 0000000..4f38257
--- /dev/null
+++ b/src/thumbs/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ MilkDrop Küçük Resim
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/thumbs/thumbs.js b/src/thumbs/thumbs.js
new file mode 100644
index 0000000..aebfc95
--- /dev/null
+++ b/src/thumbs/thumbs.js
@@ -0,0 +1,115 @@
+'use strict';
+/* MILKDROP KÜÇÜK RESİM ÇİZİCİSİ (#575). Gizli pencere; ana süreç iş
+ gönderiyor: { key, source, textureDir, textureRev, recipe }.
+
+ AYNI İŞ HER SEFERİNDE AYNI BAYTLAR:
+ - Her iş YENİ bir motor örneğinde. `dispose` bağlamı bırakıyor; önceki
+ işin geri besleme tamponu, flaş sınırlayıcısı, ses yumuşatması —
+ hiçbiri taşınmıyor. Tampon siyahtan başlıyor.
+ - `Math.random` işin anahtarından tohumlanıyor (motor her karede
+ `rand_frame` için çekiyor); presetin tohumu da anahtardan.
+ - Ses örnek ses (`demo-audio.js`), kare indisine bağlı — MilkDrop render
+ ölçümünün verdiği sesin aynısı (`scripts/milkdrop-render-rate.js`).
+ - Derleme bekleyerek (`SVMilkdropSync`).
+ - Doku isteniyorsa istendiği karenin hemen ardından bekleniyor (dışa
+ aktarıcıdaki gibi). Süre dolarsa iş BAŞARISIZ: yazılsaydı görüntü o
+ anki disk hızına kalırdı.
+ - Her kare CANLI bir bağlamda ve presetle çizilmiş olmalı. Bağlam
+ alınamazsa motor yedek bir görüntü çiziyor, kaybolursa son kareyi
+ bırakıyor; ikisi de içerik anahtarıyla kalıcı olarak saklanırdı —
+ bir GPU sıfırlaması yanlış küçük resmi sonsuza dek yerleştirirdi.
+ Motorun kare sayacı (`frameNo`) yalnız gerçekten çizilen karede
+ artıyor. */
+(function () {
+ window.SVMilkdropSync = true;
+ const bridge = window.thumbs;
+ const DEMO = window.SVDemoAudio;
+ const canvas = document.getElementById('c');
+ const out = document.createElement('canvas');
+
+ function seedRandom(key) {
+ let x = (parseInt(String(key || '').slice(0, 8), 16) >>> 0) || 1;
+ Math.random = function () {
+ x = (x * 1103515245 + 12345) % 2147483648;
+ return x / 2147483648;
+ };
+ }
+
+ /* Ölçümün sesi, kare kare: parça 1. saniyeden başlıyor; bas/orta/tiz ve
+ sentetik tayf da ölçümdekiyle aynı ('spectrum = 1' dalgaları okuyor). */
+ function audioAt(i, fps) {
+ const t = i / fps;
+ const beat = Math.pow(Math.max(0, 1 - ((t * 2) % 1) * 2.2), 2);
+ const end = Math.floor((1 + t) * DEMO.SR);
+ const time = new Uint8Array(2048);
+ const timeL = new Uint8Array(2048);
+ const timeR = new Uint8Array(2048);
+ DEMO.fill(end, time, timeL, timeR);
+ const freq = new Float32Array(512);
+ for (let q = 0; q < 512; q++) {
+ const fu = q / 512;
+ freq[q] = Math.max(0, Math.min(1, (1 - fu) * (0.35 + 0.5 * beat) + 0.15 * Math.abs(Math.sin(fu * 22 + t * 2.1))));
+ }
+ return {
+ bass: 0.45 + 0.45 * beat,
+ mid: 0.35 + 0.25 * Math.abs(Math.sin(t * 0.7)),
+ treble: 0.25 + 0.2 * Math.abs(Math.sin(t * 1.3)),
+ timeBytes: time, timeL, timeR, stereo: true, freq,
+ };
+ }
+
+ function settle(mode, ms) {
+ let timer = null;
+ const late = new Promise((r) => { timer = setTimeout(() => r(false), ms); });
+ return Promise.race([mode.whenTexturesSettled().then(() => true), late]).then((ok) => {
+ clearTimeout(timer);
+ return ok;
+ });
+ }
+
+ async function run(job) {
+ const R = job.recipe;
+ canvas.width = R.renderW;
+ canvas.height = R.renderH;
+ seedRandom(job.key);
+ const mode = new window.SVModes.milkdrop(canvas);
+ const cfg = {
+ milkdrop: {
+ presetId: 'thumb:' + job.key, source: job.source, blendTime: 0,
+ textureDir: job.textureDir || '', accurate: true,
+ },
+ milkdropLibrary: { textureRev: +job.textureRev || 0 },
+ visualizer: { sensitivity: 1 },
+ };
+ try {
+ for (let i = 0; i < R.frames; i++) {
+ mode.draw(audioAt(i, R.fps), cfg, i / R.fps, 1 / R.fps);
+ // Bu karede istenen doku (ya da doku listesi) varsa gelmesi bekleniyor
+ while (mode.texturesPending()) {
+ if (!(await settle(mode, R.textureWaitMs))) return { ok: false, error: 'TEXTURE_TIMEOUT' };
+ }
+ }
+ const gl = mode.gl;
+ if (!gl || (gl.isContextLost && gl.isContextLost()) || mode._lost || mode.recoveries ||
+ !mode.preset || mode.frameNo !== R.frames) {
+ return { ok: false, error: 'NO_CONTEXT' };
+ }
+ out.width = R.thumbW;
+ out.height = R.thumbH;
+ const c = out.getContext('2d');
+ c.imageSmoothingEnabled = true;
+ c.imageSmoothingQuality = 'high';
+ c.drawImage(canvas, 0, 0, R.thumbW, R.thumbH);
+ const blob = await new Promise((res) => out.toBlob(res, 'image/webp', R.quality));
+ if (!blob) return { ok: false, error: 'ENCODE' };
+ return { ok: true, bytes: new Uint8Array(await blob.arrayBuffer()) };
+ } finally {
+ mode.dispose();
+ }
+ }
+
+ bridge.onJob((job) => {
+ run(job).then((r) => bridge.done(job.key, r), (e) => bridge.done(job.key, { ok: false, error: String((e && e.message) || e) }));
+ });
+ bridge.ready();
+})();
diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js
index 464d6a4..d5f9925 100644
--- a/src/visualizer/modes/milkdrop.js
+++ b/src/visualizer/modes/milkdrop.js
@@ -569,6 +569,33 @@ void main(){ outColor = vCol; }`;
return h >>> 0;
}
+ /* SIFIR DOLU TAMPON (#575'te bulundu). Yeni bir hedef dokusu SIFIRLA
+ ayrılıyor; ardından gelen `clear`e güvenilmiyor. Ölçüldü: aynı GPU'da
+ başka bir MilkDrop bağlamı çizerken ve işlemci meşgulken, yeni ayrılan
+ geri besleme tamponu temizlenmiş olmasına rağmen o bağlamın görüntüsünü
+ taşıyabiliyordu — görselleştirici penceresinin çizdiği preset, küçük
+ resmi siyah olması gereken bir presete sızdı (24 çizimde 13). Geri
+ besleme bu kalıntıyı büyütüp sürdürüyor. Sıfırla ayırınca aynı koşulda
+ 24'te 0. Veri yükleme çizim durumundan (makas, maske, görünüm)
+ etkilenmiyor; temizleme etkileniyor.
+
+ Tampon hiç yazılmıyor, yani örnekler arasında paylaşılabiliyor; en
+ büyük hedefe göre büyüyor (1920x1080'de 8 MB). Pencere boyutu
+ sürüklenirken her boyutta yeniden ayırmak çöp üretirdi. */
+ let zeroBuf = new ArrayBuffer(0);
+ function zeroPixels(gl, w, h, format, type) {
+ const comps = format === gl.RGB ? 3 : 4;
+ const n = w * h;
+ let bytes;
+ let make;
+ if (type === gl.UNSIGNED_INT_2_10_10_10_REV) { bytes = n * 4; make = (b) => new Uint32Array(b, 0, n); }
+ else if (type === gl.HALF_FLOAT) { bytes = n * comps * 2; make = (b) => new Uint16Array(b, 0, n * comps); }
+ else if (type === gl.FLOAT) { bytes = n * comps * 4; make = (b) => new Float32Array(b, 0, n * comps); }
+ else { bytes = n * comps; make = (b) => new Uint8Array(b, 0, n * comps); }
+ if (zeroBuf.byteLength < bytes) zeroBuf = new ArrayBuffer(bytes);
+ return make(zeroBuf);
+ }
+
const AALINE_VERT = `#version 300 es
precision highp float;
layout(location=0) in vec2 aPos;
@@ -1263,7 +1290,11 @@ void main(){
const f = fmt || this._colorFormat();
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
- gl.texImage2D(gl.TEXTURE_2D, 0, f.internal, w, h, 0, f.format || gl.RGBA, f.type, null);
+ /* Sıfırla ayrılıyor, `null` değil (bkz. `zeroPixels`). Hizalama 1:
+ RGB satırı 4'ün katı olmayabilir. */
+ const format = f.format || gl.RGBA;
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(gl.TEXTURE_2D, 0, f.internal, w, h, 0, format, f.type, zeroPixels(gl, w, h, format, f.type));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -5457,25 +5488,37 @@ void main(){
parlaklığın KARESİYLE ağırlıklı ortalama: koyu bir arkaplanın
üstündeki küçük ama parlak ayrıntı düz ortalamada griye boğulurdu.
Geri okunan 64x16 piksel — tam kare değil — ve yalnız ışık bu kaynağı
- istediğinde, ~30 Hz ölçer mesajında. */
+ istediğinde, ~30 Hz ölçer mesajında.
+
+ İKİ TUVAL (#575'te bulundu). Küçültme GPU'daki tuvalde, okuma sık
+ okumaya ayrılmış ikinci 64x16 tuvalden. Tek tuvalde `getImageData`
+ her ~33 ms'de GPU'dan geri okuyordu ve Chromium "willReadFrequently"
+ uyarısı veriyordu (öz test uyarıyı hata sayıyor). Tek tuvali sık
+ okumaya ayırmak ise tam boy kareyi — 1080p'de ~8 MB — her seferinde
+ işlemciye çekerdi. Böyle geri okunan yalnız küçük kare, 4 KB. */
sampleColors(n) {
const src = this.canvas;
if (!src || !src.width || !src.height || typeof document === 'undefined') return [];
const cols = Math.max(1, Math.min(16, Math.round(n) || 8));
const SW = 64, SH = 16;
- if (!this._colorCanvas) {
+ const small = () => {
const c = document.createElement('canvas');
c.width = SW;
c.height = SH;
- this._colorCanvas = c;
- }
+ return c;
+ };
+ if (!this._colorCanvas) this._colorCanvas = small();
+ if (!this._colorRead) this._colorRead = small();
const x = this._colorCanvas.getContext('2d');
- if (!x) return [];
+ const xr = this._colorRead.getContext('2d', { willReadFrequently: true });
+ if (!x || !xr) return [];
x.imageSmoothingEnabled = true;
x.imageSmoothingQuality = 'high';
x.clearRect(0, 0, SW, SH);
x.drawImage(src, 0, 0, SW, SH);
- const d = x.getImageData(0, 0, SW, SH).data;
+ xr.clearRect(0, 0, SW, SH);
+ xr.drawImage(this._colorCanvas, 0, 0);
+ const d = xr.getImageData(0, 0, SW, SH).data;
const out = new Array(cols);
for (let k = 0; k < cols; k++) {
const x0 = Math.floor((k * SW) / cols);
diff --git a/tests/automation-hardware.test.js b/tests/automation-hardware.test.js
new file mode 100644
index 0000000..66e5257
--- /dev/null
+++ b/tests/automation-hardware.test.js
@@ -0,0 +1,67 @@
+'use strict';
+/* OTOMASYON FİZİKSEL IŞIKLARI SÜRMEZ (#575'te bulundu).
+ *
+ * Öz test (`--smoke`) ve ekran görüntüsü üreticisi (`--shots`) gerçek
+ * kullanıcı klasörüyle koşuyor. Kullanıcının ayarında Dynamic Lighting
+ * açıksa uygulama açılışta cihazları sürüyordu — öz testin kendisi de;
+ * öz testin adımları panelde OpenRGB'yi açıyor, bilgisayarda OpenRGB
+ * çalışıyorsa onun cihazlarını, Art-Net açıksa ağdaki sahne ışıklarını
+ * sürerdi. Kamera için kural zaten vardı (otomasyonda hiç açılmıyor).
+ *
+ * Burada kaynak denetleniyor, shutdown.test.js gibi: donanıma giden her yol
+ * tek bir bayraktan geçmeli; yeni bir yol eklenip geçmezse test düşer.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf-8');
+const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
+const M = stripComments(read('src/main/main.js'));
+
+function functionBody(src, name) {
+ const start = src.indexOf('function ' + name + '(');
+ assert.ok(start >= 0, name + '() kaynakta bulunamadı');
+ const open = src.indexOf('{', start);
+ let depth = 0;
+ for (let i = open; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) return src.slice(open, i + 1);
+ }
+ }
+ assert.fail(name + '() gövdesi kapanmıyor');
+}
+
+test('bayrak: öz test ve ekran görüntüleri, tanılama değil', () => {
+ assert.match(M, /const HW_OFF = SMOKE \|\| SHOTS;/);
+ // Tanılama ışıkları gerçekten sınamak için var: bayrağa girmemeli
+ assert.doesNotMatch(M, /const HW_OFF = [^;]*--diag/);
+});
+
+test('Dynamic Lighting: her ayar tek yoldan, otomasyonda kapalı', () => {
+ const direct = M.match(/dynamicLighting\.setConfig\(/g) || [];
+ assert.strictEqual(direct.length, 1, 'dynamicLighting.setConfig yalnız lightingSet içinde çağrılmalı');
+ const body = functionBody(M, 'lightingSet');
+ assert.match(body, /dynamicLighting\.setConfig\(HW_OFF \? Object\.assign\(\{\}, lighting, \{ enabled: false \}\) : lighting\)/);
+ // Tarama cihazlara bir anlığına el koyuyor: otomasyonda hiç yapılmıyor
+ assert.match(M, /ipcMain\.handle\('lighting:scan', \(\) => \(HW_OFF\s*\? \{ ok: true, supported: false, devices: \[\], automation: true \}\s*: dynamicLighting\.scan\(\)\)\);/);
+ assert.strictEqual((M.match(/dynamicLighting\.scan\(\)/g) || []).length, 1, 'başka bir tarama yolu yok');
+ assert.match(M, /ipcMain\.handle\('lighting:apply', \(e, lighting\) => lightingSet\(lighting\)\);/);
+});
+
+test('OpenRGB ve Art-Net: otomasyonda hiç başlatılmıyor', () => {
+ assert.match(functionBody(M, 'syncArtnet'), /if \(!a\.enabled \|\| HW_OFF\) return artnet\.stop\(\)/);
+ assert.match(functionBody(M, 'syncOpenRgb'), /if \(!o\.enabled \|\| HW_OFF\) return openrgb\.stop\(\)/);
+ // Başlatma yalnız bu iki işlevde
+ assert.strictEqual((M.match(/artnet\.start\(/g) || []).length, 1);
+ assert.strictEqual((M.match(/openrgb\.start\(/g) || []).length, 1);
+ assert.match(M, /ipcMain\.handle\('openrgb:rescan', \(\) => \(HW_OFF \? openrgb\.status\(\) : openrgb\.rescan\(\)\)\);/);
+ /* Ses karesiyle giden gönderimler başlatılmamış bir bağlantıda hiçbir şey
+ yapmıyor: başlatılmayınca paket de yok. */
+ assert.match(stripComments(read('src/main/artnet.js')), /function send\(cfg, frame\) \{\s*if \(!socket \|\| !state\.running/);
+ assert.match(stripComments(read('src/main/openrgb.js')), /function send\(cfg, rawLighting, frame, visualConfig\) \{\s*if \(!cfg \|\| !cfg\.enabled \|\| !state\.connected \|\| !socket/);
+});
diff --git a/tests/milkdrop-buffer-format.test.js b/tests/milkdrop-buffer-format.test.js
index a4719bb..8e5839d 100644
--- a/tests/milkdrop-buffer-format.test.js
+++ b/tests/milkdrop-buffer-format.test.js
@@ -73,5 +73,60 @@ test('hedefler ve bulanık kopyalar AYNI formatı kullanıyor', () => {
/* Yükleme formatı da açık verilebilmeli: flaş sınırlayıcı hedefleri
ALFASIZ (RGB8), çünkü bağlam `alpha: false` ile kuruluyor ve ekrandan
kopyalanan bir dokuda olmayan bir bileşen olamaz. */
- assert.match(mk[0], /f\.internal, w, h, 0, f\.format \|\| gl\.RGBA, f\.type/);
+ assert.match(mk[0], /const format = f\.format \|\| gl\.RGBA;/);
+ assert.match(mk[0], /f\.internal, w, h, 0, format, f\.type, zeroPixels\(gl, w, h, format, f\.type\)\)/);
+});
+
+test('hedefler SIFIRLA ayrılıyor, temizlemeye güvenilmiyor (#575)', () => {
+ /* Ölçüldü: aynı GPU'da başka bir MilkDrop bağlamı çizerken ve işlemci
+ meşgulken yeni ayrılan tampon, `clear`e rağmen o bağlamın görüntüsünü
+ taşıyabiliyordu; geri besleme onu büyütüp sürdürüyordu (24 çizimde
+ 13). Sıfırla ayırınca 24'te 0. Burada motor gerçekten koşuyor: sahte
+ bir GL'e giden yükleme sayılıyor. */
+ const vm = require('vm');
+ const canvas = () => ({ width: 64, height: 64, getContext: (k) => (k === '2d' ? {} : null), addEventListener() {} });
+ const ctx = { window: {}, document: { createElement: canvas }, console, performance };
+ ctx.window.document = ctx.document;
+ vm.createContext(ctx);
+ for (const f of ['src/shared/milkdrop.js', 'src/visualizer/modes/milkdrop.js']) {
+ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', f), 'utf-8'), ctx, { filename: f });
+ }
+ const m = new ctx.window.SVModes.milkdrop(canvas());
+ const calls = [];
+ const gl = {
+ RGBA: 6408, RGB: 6407, RGB8: 32849, RGB10_A2: 32857, UNSIGNED_BYTE: 5121, UNSIGNED_INT_2_10_10_10_REV: 33640,
+ TEXTURE_2D: 3553, UNPACK_ALIGNMENT: 3317, FRAMEBUFFER: 36160, COLOR_ATTACHMENT0: 36064, COLOR_BUFFER_BIT: 16384,
+ TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, TEXTURE_WRAP_S: 10242, TEXTURE_WRAP_T: 10243, LINEAR: 9729, CLAMP_TO_EDGE: 33071,
+ createTexture: () => ({}), bindTexture() {}, texParameteri() {}, createFramebuffer: () => ({}),
+ bindFramebuffer() {}, framebufferTexture2D() {}, clearColor() {},
+ clear: () => calls.push(['clear']),
+ pixelStorei: (k, v) => calls.push(['pixelStorei', k, v]),
+ texImage2D: (...a) => calls.push(['texImage2D'].concat(a)),
+ };
+ m.gl = gl;
+ const kind = (a) => Object.prototype.toString.call(a);
+ // Geri besleme: RGB10_A2, piksel başına bir 32 bit sözcük
+ m._makeTarget(8, 4, { internal: gl.RGB10_A2, type: gl.UNSIGNED_INT_2_10_10_10_REV });
+ const up1 = calls.filter((c) => c[0] === 'texImage2D').pop();
+ const d1 = up1[9];
+ assert.strictEqual(kind(d1), '[object Uint32Array]');
+ assert.strictEqual(d1.length, 32);
+ assert.ok(Array.from(d1).every((v) => v === 0), 'tamamı sıfır');
+ const iAlign = calls.findIndex((c) => c[0] === 'pixelStorei' && c[1] === gl.UNPACK_ALIGNMENT && c[2] === 1);
+ const iUp = calls.indexOf(up1);
+ assert.ok(iAlign >= 0 && iAlign < iUp, 'hizalama yüklemeden önce 1');
+ assert.ok(calls.slice(iUp).some((c) => c[0] === 'clear'), 'temizleme yine yapılıyor');
+ // Flaş sınırlayıcı: RGB8, satır 4'ün katı değil
+ m._makeTarget(5, 3, { internal: gl.RGB8, format: gl.RGB, type: gl.UNSIGNED_BYTE });
+ const up2 = calls.filter((c) => c[0] === 'texImage2D').pop();
+ assert.strictEqual(kind(up2[9]), '[object Uint8Array]');
+ assert.strictEqual(up2[9].length, 45);
+ assert.strictEqual(up2[7], gl.RGB);
+ // Tampon paylaşılıyor (küçük istek aynı tampon), büyük istek büyütüyor
+ assert.strictEqual(up2[9].buffer, d1.buffer, 'küçük hedef aynı sıfır tamponu');
+ m._makeTarget(64, 64, { internal: gl.RGB10_A2, type: gl.UNSIGNED_INT_2_10_10_10_REV });
+ const up3 = calls.filter((c) => c[0] === 'texImage2D').pop();
+ assert.strictEqual(up3[9].length, 4096);
+ assert.ok(up3[9].buffer.byteLength >= 4096 * 4);
+ assert.ok(Array.from(up3[9]).every((v) => v === 0));
});
diff --git a/tests/milkdrop-lights.test.js b/tests/milkdrop-lights.test.js
index 11f453c..d14e593 100644
--- a/tests/milkdrop-lights.test.js
+++ b/tests/milkdrop-lights.test.js
@@ -31,14 +31,22 @@ const ART = require('../src/main/artnet.js');
Sahte tuval, motorun istediği küçültmeyi bu işlevden dolduruyor. */
function engineWith(paint) {
const calls = [];
+ // Hangi tuvale ne yapıldı: sık okumaya ayrılmış mı, kaynak ne boyda
+ const ops = [];
const canvas = (w, h) => {
const c = { width: w || 0, height: h || 0, addEventListener() {} };
- c.getContext = (k) => {
+ c.getContext = (k, opts) => {
if (k !== '2d') return null;
+ if (opts) c.opts = opts;
+ const freq = () => !!(c.opts && c.opts.willReadFrequently);
return {
clearRect() {},
- drawImage(src, x, y, dw, dh) { calls.push([src === c ? 'self' : 'src', dw, dh]); },
+ drawImage(src, x, y, dw, dh) {
+ calls.push([src === c ? 'self' : 'src', dw, dh]);
+ ops.push(['draw', freq(), src.width]);
+ },
getImageData(x0, y0, w2, h2) {
+ ops.push(['read', freq(), w2 * h2]);
const data = new Uint8ClampedArray(w2 * h2 * 4);
for (let yy = 0; yy < h2; yy++) {
for (let xx = 0; xx < w2; xx++) {
@@ -58,7 +66,7 @@ function engineWith(paint) {
vm.createContext(ctx);
vm.runInContext(read('src/visualizer/modes/milkdrop.js'), ctx, { filename: 'milkdrop.js' });
const m = new ctx.window.SVModes.milkdrop(canvas(1920, 1080));
- return { m, calls };
+ return { m, calls, ops };
}
// ------------------------------------------------------------ örnekleyici
@@ -71,6 +79,21 @@ test('renkler soldan sağa dilimlerden: sol yarı kırmızı, sağ yarı mavi',
assert.deepStrictEqual(Array.from(calls[0]), ['src', 64, 16]);
});
+test('geri okuma küçük tuvalden; tam boy kare GPU tuvalinde küçültülüyor (#575)', () => {
+ /* Tek tuvalde `getImageData` Chromium'un "willReadFrequently" uyarısını
+ veriyordu (öz test onu hata sayıyor); tuvali sık okumaya ayırmak ise
+ tam boy kareyi her seferinde işlemciye çekerdi. */
+ const { m, ops } = engineWith(() => [200, 0, 0]);
+ m.sampleColors(8);
+ m.sampleColors(8);
+ const reads = ops.filter((o) => o[0] === 'read');
+ assert.ok(reads.length === 2 && reads.every((o) => o[1] === true && o[2] === 64 * 16), 'okuma sık okumaya ayrılmış 64x16 tuvalden');
+ const full = ops.filter((o) => o[0] === 'draw' && o[2] === 1920);
+ assert.ok(full.length === 2 && full.every((o) => o[1] === false), 'tam boy kare sık okumaya ayrılmamış tuvalde küçültülüyor');
+ const copy = ops.filter((o) => o[0] === 'draw' && o[2] === 64);
+ assert.ok(copy.length === 2 && copy.every((o) => o[1] === true), 'küçük kare okuma tuvaline kopyalanıyor');
+});
+
test('koyu arkaplandaki küçük parlak ayrıntı dilimin rengini veriyor', () => {
/* Düz ortalamada birkaç yeşil piksel griye boğulurdu; ağırlık
parlaklığın karesi olduğu için dilim yeşil çıkıyor. */
diff --git a/tests/milkdrop-thumbs.test.js b/tests/milkdrop-thumbs.test.js
new file mode 100644
index 0000000..3723318
--- /dev/null
+++ b/tests/milkdrop-thumbs.test.js
@@ -0,0 +1,619 @@
+'use strict';
+/* MILKDROP KÜÇÜK RESİMLERİ (#575). Her preset bir kez, gizli bir pencerede
+ * küçük çiziliyor ve kullanıcı verisinde saklanıyor; panelde ızgara.
+ *
+ * Reçete ölçüldü (korpustan tohumlu 200 preset, bkz. src/main/milkdrop-thumbs.js):
+ * siyah tampondan 60 kare, 640x360 çizilip 256x144 WebP. Aynı preset hangi
+ * presetten sonra çizilirse çizilsin aynı baytlar (8/8).
+ */
+global.window = global.window || {};
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const vm = require('vm');
+const T = require('../src/main/milkdrop-thumbs.js');
+const TEX = require('../src/main/milkdrop-textures.js');
+require('../src/shared/defaults.js');
+const L = require('../src/shared/milkdrop-library.js');
+
+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 ');
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+/* Koşul gerçekleşene kadar yokla. Sabit bir bekleme panelin 80 ms'lik
+ toplama zamanlayıcısıyla yarışırdı: Windows'ta zamanlayıcı ~15,6 ms'de
+ bir işliyor, yüklü bir CI makinesinde daha da geç. */
+async function until(fn, ms) {
+ const end = Date.now() + (ms || 3000);
+ for (;;) {
+ const v = fn();
+ if (v || Date.now() > end) return v;
+ await wait(10);
+ }
+}
+
+const made = [];
+test.after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
+function tmpDir() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-thumb-'));
+ made.push(d);
+ return d;
+}
+const K = (c) => c.repeat(40).slice(0, 40); // geçerli biçimde anahtar
+
+// ---------------------------------------------------------------- reçete
+
+test('reçete: ölçülen değerler; sayfa betikleri listedekiyle aynı sırada', () => {
+ assert.deepStrictEqual(
+ [T.RECIPE.renderW, T.RECIPE.renderH, T.RECIPE.frames, T.RECIPE.fps, T.RECIPE.thumbW, T.RECIPE.thumbH],
+ [640, 360, 60, 30, 256, 144]);
+ const html = read('src/thumbs/index.html');
+ const scripts = Array.from(html.matchAll(/