From c1049ab76502eeaeb778a12b965f292d00ece1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 21:32:04 +0300 Subject: [PATCH 1/4] feat(milkdrop): preset thumbnails in a grid 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. - 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. A fixed seed image cut black thumbnails only from 20 to 18, took 45% longer and painted its colours into presets that draw nothing like it; more frames did not help. - Each thumbnail is drawn by a new engine instance from black buffers, with Math.random and the preset seed taken from the key, and waits for the textures it asks for; the same preset gives the same bytes whatever was drawn before it. A texture that does not arrive is not written. - The key hashes the files the thumbnail page loads, the preset source and the textures it uses, so an engine change, an edited preset or a changed texture gets a new thumbnail. Unused files are deleted once per session. - The panel asks for the visible cells; cached thumbnails come back at once, the rest as they are drawn, newest request first. Waiting and failed cells look different from a thumbnail that is really black. Images come through an sv-thumb: protocol that accepts only a key. - The hidden window closes when idle and when the panel closes. - The smoke test draws a builtin preset's thumbnail through the whole chain into a temporary folder. Found on the way: - sampler_randNN picked from the texture list in file-system order, which differs between Windows and Linux; the list is now sorted. - The importer did not strip filter/wrap prefixes from sampler names, so a preset asking for sampler_fw_worms did not bring worms.jpg along. - The builtin presets module now gives its list to Node too. Refs #575, #560 --- README.md | 10 +- README.tr.md | 10 +- ROADMAP.md | 69 +++- src/admin/index.html | 2 +- src/admin/milkdrop-panel.js | 222 +++++++++++-- src/admin/studio.css | 90 ++++++ src/main/main.js | 278 +++++++++++++++- src/main/milkdrop-import.js | 17 +- src/main/milkdrop-textures.js | 36 ++- src/main/milkdrop-thumbs.js | 240 ++++++++++++++ src/main/preload-admin.js | 5 + src/main/preload-thumbs.js | 16 + src/shared/i18n.js | 8 + src/shared/presets-milkdrop.js | 23 +- src/thumbs/index.html | 24 ++ src/thumbs/thumbs.js | 104 ++++++ tests/milkdrop-thumbs.test.js | 574 +++++++++++++++++++++++++++++++++ tests/shutdown.test.js | 11 +- 18 files changed, 1676 insertions(+), 63 deletions(-) create mode 100644 src/main/milkdrop-thumbs.js create mode 100644 src/main/preload-thumbs.js create mode 100644 src/thumbs/index.html create mode 100644 src/thumbs/thumbs.js create mode 100644 tests/milkdrop-thumbs.test.js diff --git a/README.md b/README.md index 8cc0fdc..83cb598 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-2081%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2102%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -360,6 +360,12 @@ 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. - **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 +1265,7 @@ npm test npm start -- --smoke ``` -**2081 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2102 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..bf7eebc 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-2081%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2102%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -353,6 +353,12 @@ 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. - **`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 +1262,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: +**2102 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..d7c3589 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; +- **2102 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 505 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,71 @@ 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. + Checked in the real page: a preset drawn after two different presets + gave the same bytes, with and without a texture. + - **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:** `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. + - **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. 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. + - **Tests.** 21 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), 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. 40 of 40 mutations are caught. ## 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..5ec5933 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. @@ -1017,14 +1019,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(); } // ---------------------------------------------------------------------------- @@ -2289,6 +2294,217 @@ 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); } + }); + 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 // @@ -2883,6 +3099,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 +3509,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 +5285,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..25c5176 --- /dev/null +++ b/src/thumbs/thumbs.js @@ -0,0 +1,104 @@ +'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ı. */ +(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' }; + } + } + 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/tests/milkdrop-thumbs.test.js b/tests/milkdrop-thumbs.test.js new file mode 100644 index 0000000..5b596ed --- /dev/null +++ b/tests/milkdrop-thumbs.test.js @@ -0,0 +1,574 @@ +'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)); + +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(/