diff --git a/README.md b/README.md index fb855c8..92fe997 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-2108%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2147%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -369,6 +369,17 @@ that asserts the bar profile has no step in it. 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. +- **A preset generator of our own.** *Studio → MilkDrop Preset Generator* writes an original + MilkDrop preset from four sliders — energy, warmth, density and motion — and a seed. It runs + offline, and every motion, wave, shape and shader pattern it uses was written for this app: no + line of it longer than 30 characters appears in a 10,332-preset corpus. The preset loads straight + away as a preview without being saved; *Save to Library* keeps it. Moving a slider keeps the + seed, so you get the same preset with more energy or warmer colours rather than a different one, + and a code such as `72-15-60-88-2n9c` brings the same preset back. Nothing in a generated preset + flashes: its colours stay in range at any volume, and invert, solarize and brighten are never + used. Measured on 200 generated presets with silence, the demo sound and a loud bass line: every + shader compiled, and none went black, washed out to white, flashed more than twice a second or + froze. - **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 @@ -1268,7 +1279,7 @@ npm test npm start -- --smoke ``` -**2108 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2147 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 91d763d..db5a546 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-2108%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2147%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -362,6 +362,17 @@ profilinde basamak olmadığını doğruluyor. 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. +- **Kendi preset üreticimiz.** *Studio → MilkDrop Preset Üretici* dört kaydırıcıdan — enerji, + sıcaklık, yoğunluk ve hareket — ve bir tohumdan özgün bir MilkDrop preseti yazıyor. Çevrimdışı + çalışıyor; kullandığı hareket, dalga, şekil ve shader kalıplarının hepsi bu uygulama için + yazıldı: 30 karakterden uzun hiçbir satırı 10.332 presetlik bir korpusta geçmiyor. Preset + kaydedilmeden, önizleme olarak hemen yükleniyor; *Kütüphaneye Kaydet* onu saklıyor. Kaydırıcıyı + oynatmak tohumu değiştirmiyor: başka bir preset değil, aynı presetin daha enerjik ya da daha sıcak + renkli hâli geliyor; `72-15-60-88-2n9c` gibi bir kod aynı preseti geri getiriyor. Üretilen + presette flaş yok: renkleri her ses düzeyinde aralıkta kalıyor, ters çevirme, solarize ve + parlatma hiç kullanılmıyor. 200 üretilmiş presette sessizlik, örnek ses ve yüksek bir bas + çizgisiyle ölçüldü: bütün shader'lar derlendi; hiçbiri siyah kalmadı, beyaza doymadı, saniyede + ikiden çok yanıp sönmedi ya da donmadı. - **`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 @@ -1265,7 +1276,7 @@ npm test npm start -- --smoke ``` -**2108 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2147 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 799671a..bfdcf0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2108 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2147 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 511 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 550 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1858,6 +1858,96 @@ rest after. No version number yet. 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). +- **A preset generator of our own (#579)** · the generator is done; mash-ups + are next. + - **What it does.** *Studio → MilkDrop Preset Generator* writes an + original `.milk` from four sliders and a seed. Energy sets how strongly + zoom, rotation, warp and the size and brightness of shapes and waves + follow bass, mids and treble; warmth picks the palette (icy blue, + violet, pink, orange); density adds custom waves, shapes, shape + instances and per-pixel terms; motion scales every time coefficient. + Six motion patterns (tunnel, vortex, rings, current, bloom, breath), six + wave patterns, four shape patterns (a breathing core, orbiting + instances, a rotating frame, and a textured shape that draws the + previous frame into itself), four composite and two warp shaders — all + written for this app, in MilkDrop 2's file format. The version lines are + written only when there is a shader, so a shaderless preset is a + MilkDrop 1 file. + - **Preview, then save.** *Generate* loads the preset through the + MilkDrop panel's own selection path — the layer stack switches to + MilkDrop, the history records it, every window shows it — without + saving it. Rating, favorite and tags stay off for an unsaved preset, ◀ + skips it and *Next* goes on from the top of the list. *Save to Library* + saves the generator's last result, not whatever is on screen: + auto-advance may have moved on. The id comes from the code + (`md_gen1_72-15-60-88-2n9c`), so saving the same preset twice leaves one + file. The author is *Generator* — *Üretici* in the Turkish interface: + like the name, it is written in the interface's language when the + preset is saved — so the author filter lists the generated presets + together as long as the language stays the same. + - **A code brings it back.** `energy-warmth-density-motion-seed`. Spaces, + capitals and leading zeros read as the same code; an axis above 100 + makes the code invalid instead of clamping it to another preset. A + slider keeps the seed: each part draws from its own random stream and + the number of draws does not depend on the axes, so warmth changes only + colours and density adds elements without re-rolling the palette. The + same code writes the same file on Windows and Linux under Node 20 and + 22: every number written goes through arithmetic and rounding only; + sin, pow and exp run in the preset, not in the generator. Seven golden + hashes pin it, and a change to the patterns must bump the version in + the id so earlier saves are not overwritten. + - **Safe by construction.** Invert, solarize, brighten and darken stay + off; no step functions (`above`, `below`, `if`) and no `rand`. Colour + and alpha expressions are built from the numbers they print, so they + stay in [0, 1] at any volume — MilkDrop wraps a colour above 1 instead + of clamping it, and a wrap flickers with the beat. Bass, mids and treble + are capped at 2.5 inside the preset. A warp shader dims the picture + itself, independent of the frame rate (`q8 = pow(decay, 30/fps)`). + - **Measured.** 200 generated presets — every 0/100 corner of the four + axes twice, and 168 random points — each drawn for 5 s at 320×180 with + silence, the demo sound and a loud bass line, flash limiter and reduced + motion off. No shader stage fell back to the fixed pipeline, and all + 200 were clean with all three sounds: never black (at least 4% of the + screen lit in silence, 9% with sound), never washed out (at most 33% + near white), at most 2 flashes a second in any sixteenth of the screen + (in 2 of the 600 runs; 587 had none), and never frozen (at least 4.7% + of the pixels visibly changing within a second). A frame took 3.0 to + 3.4 ms (median). 40 of them at 960×720: all clean, at most 15% near + white, at most 2 flashes a second (in 1 of the 120 runs). All of these + figures come from the committed generator, measured again after the + last change. Every line and statement longer than + 30 characters in 2,000 generated presets (73,405 fragments) was looked + up in the 10,332-preset corpus (30,563 distinct long lines): none is + there. In an isolated copy the card generated a preset and the + visualizer window drew it, a slider kept the seed, a typed code brought + its preset back, saving twice left one file, the favorite worked after + saving, and the English UI had no Turkish left. + - **Found by the measurement.** A sparse preset with only the main wave + came out nearly black: the wave is a thin line, and modes 1, 2, 3 and 5 + draw the left channel against the right, which collapses to a point in + silence. A sparse preset now always gets one custom wave, those modes + and a dotted main wave need at least two elements, and the centre is + darkened only when density leaves something elsewhere. A decay of 0.99 + let a thick additive line drawn in the same place every frame fill 61% + of the screen with white, even in silence: decay stops at 0.98 and the + main wave's alpha follows it. A ring or a round core that only rotates + looked frozen in silence: both now change over time. An edge-sharpening + composite made a fast Lissajous figure flash three times a second in + one part of the screen: its gains are lower. + - **Not done:** mash-ups (the second half of #579); a MIDI/OSC action and + a web-remote button that generate. + - **Tests.** 37 tests. 24 for the generator: the code and id (round trip, + one spelling, invalid codes, one file in the real store), determinism + and golden hashes, 512 presets on an axis grid compiled block by block + with every function known, shaders translated without hard or + approximate notes, number formatting, nothing that flashes, colours in + range at five volumes and five times, decay, gamma and the main wave's + alpha, the rules the measurement found, the four axes' effects averaged + over 60 seeds, the independent streams, and the three range helpers + directly. 13 for the panel with a fake DOM: an unsaved preview leaves + rating, favorite and tags alone, ◀ skips it, saving writes the last + result under the same id and the list takes it at once, the code field, + the sliders, 🎲 and the wiring. 26 of 26 mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/src/admin/admin.js b/src/admin/admin.js index 30e13f8..0dd6c48 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -499,6 +499,8 @@ return window.SVMappingPanel ? window.SVMappingPanel.panel() : null; case 'milkdroppanel': return window.SVMilkdropPanel ? window.SVMilkdropPanel.panel() : null; + case 'mdgenpanel': + return window.SVMdGenPanel ? window.SVMdGenPanel.panel() : null; case 'recordpanel': return window.SVRecordPanel ? window.SVRecordPanel.panel() : null; case 'templatepanel': @@ -2823,6 +2825,15 @@ desc: 'Ruh halini yaz, uygulama sana uygun bir sahne kursun. Tamamen çevrimdışı çalışır.', controls: [{ type: 'scenegen' }], }, + { + id: 'mdgen', + roots: [], + category: 'studio', + icon: '🧬', + title: 'MilkDrop Preset Üretici', + desc: 'Enerji, sıcaklık, yoğunluk ve hareketten özgün bir MilkDrop preseti yazar. Tamamen çevrimdışı; beğendiğinizi kütüphaneye kaydedin.', + controls: [{ type: 'mdgenpanel' }], + }, { id: 'logo', roots: ['logo'], diff --git a/src/admin/index.html b/src/admin/index.html index 5af3cc0..efc13ac 100644 --- a/src/admin/index.html +++ b/src/admin/index.html @@ -326,6 +326,7 @@

Ayarlar

+ @@ -372,6 +373,7 @@

Ayarlar

+ diff --git a/src/admin/milkdrop-gen.js b/src/admin/milkdrop-gen.js new file mode 100644 index 0000000..fd41b22 --- /dev/null +++ b/src/admin/milkdrop-gen.js @@ -0,0 +1,195 @@ +'use strict'; +/* MİLKDROP PRESET ÜRETİCİ — arayüz (#579). Üretici: shared/milkdrop-generator.js. + + Dört kaydırıcı, bir kod alanı ve üç düğme. Kaydırıcı bırakılınca AYNI + TOHUMLA yeniden üretilip yükleniyor: tohum sabit kaldığı için kaydırıcı + başka bir preset değil, aynı presetin daha enerjik ya da daha sıcak + hâlini veriyor. Sürüklerken değil bırakınca, çünkü her yükleme bir + yapılandırma gönderip geçişi baştan başlatıyor. + + ÖNİZLEME KAYDEDİLMİYOR. Üretilen preset MilkDrop panelinin elle seçim + yolundan yükleniyor (`SVMilkdropPanel.preview`): katman yığını MilkDrop'a + dönüyor, geçmişe giriyor, bütün pencereler görüyor. Kimliği listede + olmadığı için puan, favori ve etiket kapalı; kaydedilince aynı kimlikle + açılıyorlar. + + "Kütüphaneye Kaydet" ÜRETİCİNİN SON SONUCUNU yazıyor, ekrandakini değil: + otomatik geçiş o arada başka bir presete geçmiş olabilir. Kimlik koddan + geldiği için aynı preseti iki kez kaydetmek ikinci bir kopya bırakmıyor. + + Durum modülde, ayarlarda değil (Sahne Üretici gibi): kaydırıcıların + konumu bir sahnenin parçası değil. */ +(function () { + const P = () => window.SVPanel; + const G = () => window.SVMdGen; + const MP = () => window.SVMilkdropPanel; + const tr = (s) => (window.SVI18n && window.SVI18n.t ? window.SVI18n.t(s) : s); + const lang = () => (window.SVI18n && window.SVI18n.locale === 'en' ? 'en' : 'tr'); + + const axes = { energy: 50, warmth: 50, density: 50, motion: 50 }; + let seed = null; + let last = null; + let saving = false; + + // Eksen, etiket ve iki ucun adı + const AXIS_ROWS = [ + ['energy', 'Enerji', 'sakin', 'coşkun'], + ['warmth', 'Sıcaklık', 'soğuk', 'sıcak'], + ['density', 'Yoğunluk', 'seyrek', 'yoğun'], + ['motion', 'Hareket', 'yavaş', 'hızlı'], + ]; + const MOTION_LABELS = { + tunel: 'Tünel', girdap: 'Girdap', halka: 'Halkalar', akinti: 'Akıntı', cicek: 'Çiçek', nefes: 'Nefes', + }; + const SHADER_LABELS = { yok: 'Yok', comp: 'Birleştirme', warp: 'Warp', ikisi: 'Warp ve birleştirme' }; + + function make() { + if (seed === null) seed = G().randomSeed(); + last = G().generate({ axes, seed, lang: lang() }); + return last; + } + + // Üretir ve yükler; MilkDrop paneli yoksa (yükleme hatası) söylüyor + function regenerate() { + if (!G()) return; + const r = make(); + const panel = MP(); + if (!panel || !panel.preview || !panel.preview({ id: r.id, kind: 'milkdrop', name: r.name, source: r.source })) { + P().toast(tr('MilkDrop paneli kullanılamıyor.'), 'err'); + P().rerender(); + } + } + + function fromCode(text) { + const d = G() && G().decode(text); + if (!d) { + P().toast(tr('Kod okunamadı. Biçim: enerji-sıcaklık-yoğunluk-hareket-tohum'), 'err'); + return; + } + Object.assign(axes, d.axes); + seed = d.seed; + regenerate(); + } + + const inLibrary = (id) => { + const S = window.SVPresets; + return !!(S && typeof S.user === 'function' && S.user().some((p) => p && p.id === id)); + }; + + async function save() { + const r = last; + if (!r || saving) return; + if (!window.api || !window.api.savePreset) { + P().toast(tr('Kaydetme kullanılamıyor.'), 'err'); + return; + } + saving = true; + P().rerender(); + let res = null; + try { + /* Yazar alanı yazar süzgecinde üretilenleri bir arada tutuyor; kayıt + anındaki arayüz dilinde, çünkü adı da o dilde. */ + res = await window.api.savePreset({ + id: r.id, kind: 'milkdrop', name: r.name, source: r.source, author: tr('Üretici'), + }); + } catch (e) { + res = null; + } + saving = false; + if (res && res.ok) { + const panel = MP(); + if (panel && panel.adopt) panel.adopt(res.preset); + else P().rerender(); + P().toast(tr('Preset kütüphaneye kaydedildi.'), 'ok'); + } else { + P().rerender(); + P().toast(tr('Kaydedilemedi.'), 'err'); + } + } + + function axisRow(key, label, lo, hi) { + const el = P().el; + const val = el('span', { class: 'val', text: String(axes[key]) }); + const input = el('input', { + type: 'range', min: 0, max: 100, step: 1, value: axes[key], + 'aria-label': tr(label), + oninput: (e) => { val.textContent = e.target.value; }, + onchange: (e) => { + axes[key] = Math.max(0, Math.min(100, Math.round(Number(e.target.value)) || 0)); + regenerate(); + }, + }); + return el('div', { class: 'ctrl' }, [ + el('div', { class: 'row' }, [el('label', { class: 'lbl', text: label }), val]), + input, + el('div', { class: 'mdgen-ends dim-hint' }, [el('span', { text: lo }), el('span', { text: hi })]), + ]); + } + + function panel() { + const el = P().el; + const nodes = []; + if (!G()) { + nodes.push(el('div', { class: 'studio-note md-err', text: 'Üretici yüklenemedi.' })); + return el('div', {}, nodes); + } + for (const [key, label, lo, hi] of AXIS_ROWS) nodes.push(axisRow(key, label, lo, hi)); + + const codeIn = el('input', { + class: 'p-in', type: 'text', value: last ? last.code : '', + placeholder: tr('ör. 50-50-50-50-k3x9ab'), spellcheck: 'false', 'aria-label': tr('Kod'), + // Enter da değişikliği bildiriyor; ikinci bir tuş dinleyicisi çift üretirdi + onchange: (e) => fromCode(e.target.value), + }); + nodes.push(P().row('Kod', codeIn)); + + nodes.push(el('div', { class: 'gen-actions' }, [ + el('button', { + class: 'btn primary', type: 'button', text: '✨ Preset Üret', + title: 'Kaydırıcılardaki eksenlerle üretir ve yükler', + onclick: () => regenerate(), + }), + el('button', { + class: 'btn', type: 'button', text: '🎲 Karıştır', + title: 'Aynı eksenler, başka bir tohum', + onclick: () => { seed = G().randomSeed(); regenerate(); }, + }), + el('button', { + class: 'btn', type: 'button', text: saving ? 'Kaydediliyor…' : '💾 Kütüphaneye Kaydet', + disabled: !last || saving, + onclick: () => save(), + }), + ])); + + if (last) { + const kept = inLibrary(last.id); + // Etiket ve değer ayrı düğümlerde: çeviri sözlüğü tam metin eşliyor + const chip = (k, v) => el('span', { class: 'gen-chip' }, [el('b', { text: k }), el('i', { text: v })]); + const chips = el('div', { class: 'gen-chips' }, [ + chip('Hareket', MOTION_LABELS[last.parts.motion] || last.parts.motion), + chip('Dalgalar', String(last.parts.waves.length)), + chip('Şekiller', String(last.parts.shapes.length)), + chip('Shader', SHADER_LABELS[last.parts.shader] || last.parts.shader), + ]); + nodes.push(el('div', { class: 'studio-note' }, [ + el('div', { class: 'gen-pair' }, [ + el('span', { class: 'dim-hint', text: 'Son Üretilen' }), + // Presetin adı onun kendi adı, arayüz metni değil (üretildiği dilde) + el('span', { class: 'md-cur mdgen-name', text: last.name }), + el('span', { class: 'dim-hint', text: 'Durum' }), + el('span', { class: kept ? 'md-ok' : 'dim-hint', text: kept ? '✓ Kütüphanede' : 'Önizleme — kaydedilmedi' }), + ]), + chips, + ])); + } + + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Tamamen bu bilgisayarda çalışır, hiçbir servise bağlanmaz. Kaydırıcıyı bırakınca aynı tohumla yeniden üretilir; 🎲 başka bir tohum dener. Kod eksenleri ve tohumu taşır: aynı kod her zaman aynı preseti verir. Önizleme kütüphaneye yazılmaz; puan, favori ve etiket kaydettikten sonra açılır. Hareket, dalga, şekil ve shader kalıpları bu uygulamada yazıldı, hiçbir preset paketinden alınmadı.', + })); + return el('div', { class: 'mdgen-panel' }, nodes); + } + + window.SVMdGenPanel = { panel, regenerate, save, fromCode, state: () => ({ axes: Object.assign({}, axes), seed, last }) }; + if (typeof module !== 'undefined' && module.exports) module.exports = window.SVMdGenPanel; +})(); diff --git a/src/admin/milkdrop-panel.js b/src/admin/milkdrop-panel.js index 784e288..9524b99 100644 --- a/src/admin/milkdrop-panel.js +++ b/src/admin/milkdrop-panel.js @@ -565,6 +565,26 @@ const byId = (id) => presets.find((p) => p.id === id); + /* ÜRETİCİ (#579). Üretilen preset KAYDEDİLMEDEN elle seçim gibi + yükleniyor: aynı yol, yani katman yığını MilkDrop'a dönüyor, geçmişe + giriyor ve bütün pencereler görüyor. Kimliği listede olmadığı için + puan, favori ve etiket kapalı (üçü de `byId` soruyor), ◀/▶ geçmişteki + kaydını atlıyor, liste adımı listenin başından sürüyor. */ + function preview(p) { + if (!p || typeof p.id !== 'string' || !p.id || typeof p.source !== 'string') return false; + go(P().cfg(), p); + return true; + } + + /* Kaydedilen preset HEMEN listeye. Kaydı başka bir kart yapıyor; değişiklik + yayını gelene kadar liste eski kalır, ekrandaki önizlemenin yıldızları + ve favori düğmesi bir çizim boyunca kapalı görünürdü. */ + function adopt(saved) { + if (!saved || !saved.id) return; + adoptDelta({ upsert: [saved] }); + refresh(() => P().rerender()); + } + /* Liste adımı EKRANDAKİ presete göre: otomatik geçiş ilerlemişse "sonraki" ayardaki elle seçimin değil, o an görülenin sonraki (MilkDrop'ta da `m_nCurrentPreset` gösterileni tutuyor). */ @@ -1766,7 +1786,7 @@ sinanabilsin. */ window.SVMilkdropPanel = { panel, init, refresh, load, pointStackAtMilkdrop, noteLive, liveId, history, act, fillStars, - spriteTargets, + spriteTargets, preview, adopt, }; if (typeof module !== 'undefined' && module.exports) { module.exports = window.SVMilkdropPanel; diff --git a/src/admin/studio.css b/src/admin/studio.css index 1ededb6..e6ad8b6 100644 --- a/src/admin/studio.css +++ b/src/admin/studio.css @@ -200,6 +200,9 @@ input.p-in[type='color'] { padding: 2px; height: 28px; width: 46px; flex: none; .gen-chip b { color: var(--muted2); font-weight: 600; } .gen-chip i { color: var(--text); font-style: normal; } .gen-pair { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 11px; align-items: baseline; } +/* MilkDrop üreticisi (#579): kaydırıcının iki ucunun adı */ +.mdgen-ends { display: flex; justify-content: space-between; font-size: 10.5px; margin-top: -2px; } +.mdgen-name { font-weight: 600; } .st-num { font-variant-numeric: tabular-nums; } .st-sep { opacity: .5; } diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 2c2e4f8..304619a 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1730,6 +1730,43 @@ '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', + // ---- MilkDrop preset üretici (#579) ---- + // 'Enerji', 'Sıcaklık', 'Yoğunluk', 'Hareket', 'sakin', 'soğuk', 'sıcak', 'Tünel', 'Girdap', 'Yok', + // 'Durum', 'Shader', '🎲 Karıştır' ve 'Kaydediliyor…' başka yerlerde zaten var + 'MilkDrop Preset Üretici': 'MilkDrop Preset Generator', + 'Enerji, sıcaklık, yoğunluk ve hareketten özgün bir MilkDrop preseti yazar. Tamamen çevrimdışı; beğendiğinizi kütüphaneye kaydedin.': + 'Writes an original MilkDrop preset from energy, warmth, density and motion. Fully offline; save the ones you like to the library.', + 'coşkun': 'energetic', + 'seyrek': 'sparse', + 'yoğun': 'dense', + 'yavaş': 'slow', + 'hızlı': 'fast', + 'Halkalar': 'Rings', + 'Akıntı': 'Current', + 'Çiçek': 'Bloom', + 'Nefes': 'Breath', + 'Birleştirme': 'Composite', + 'Warp ve birleştirme': 'Warp and composite', + 'MilkDrop paneli kullanılamıyor.': 'The MilkDrop panel is not available.', + 'Kod okunamadı. Biçim: enerji-sıcaklık-yoğunluk-hareket-tohum': 'Could not read the code. Format: energy-warmth-density-motion-seed', + 'Kaydetme kullanılamıyor.': 'Saving is not available.', + 'Üretici': 'Generator', + 'Preset kütüphaneye kaydedildi.': 'Preset saved to the library.', + 'Kaydedilemedi.': 'Could not save.', + 'Üretici yüklenemedi.': 'The generator could not be loaded.', + 'ör. 50-50-50-50-k3x9ab': 'e.g. 50-50-50-50-k3x9ab', + 'Kod': 'Code', + '✨ Preset Üret': '✨ Generate Preset', + 'Kaydırıcılardaki eksenlerle üretir ve yükler': 'Generates and loads a preset from the slider axes', + 'Aynı eksenler, başka bir tohum': 'Same axes, another seed', + '💾 Kütüphaneye Kaydet': '💾 Save to Library', + 'Dalgalar': 'Waves', + 'Şekiller': 'Shapes', + 'Son Üretilen': 'Last Generated', + '✓ Kütüphanede': '✓ In the library', + 'Önizleme — kaydedilmedi': 'Preview — not saved', + 'Tamamen bu bilgisayarda çalışır, hiçbir servise bağlanmaz. Kaydırıcıyı bırakınca aynı tohumla yeniden üretilir; 🎲 başka bir tohum dener. Kod eksenleri ve tohumu taşır: aynı kod her zaman aynı preseti verir. Önizleme kütüphaneye yazılmaz; puan, favori ve etiket kaydettikten sonra açılır. Hareket, dalga, şekil ve shader kalıpları bu uygulamada yazıldı, hiçbir preset paketinden alınmadı.': + 'Runs entirely on this computer and connects to no service. Releasing a slider generates again with the same seed; 🎲 tries another seed. The code carries the axes and the seed: the same code always gives the same preset. A preview is not written to the library; rating, favorite and tags open once you save it. The motion, wave, shape and shader patterns were written in this app, not taken from any preset pack.', 'MilkDrop': 'MilkDrop', diff --git a/src/shared/milkdrop-generator.js b/src/shared/milkdrop-generator.js new file mode 100644 index 0000000..c501939 --- /dev/null +++ b/src/shared/milkdrop-generator.js @@ -0,0 +1,1079 @@ +'use strict'; +/* MİLKDROP PRESET ÜRETİCİ (#579). + + Dört eksenden ve bir tohumdan özgün bir .milk preseti yazar: enerji (sese + tepkinin gücü), sıcaklık (renk ailesi), yoğunluk (öğe sayısı ve ayrıntı) + ve hareket (hız). Sahne Üretici'nin (admin/scenegen.js) MilkDrop + karşılığı: çevrimdışı, hesapsız; aynı eksenler ve aynı tohum her zaman + aynı preseti verir. + + BLOKLAR BU DOSYADA YAZILDI. Hareket, dalga, şekil ve shader kalıpları + hiçbir preset paketinden alınmadı. Test, üretilen denklem satırlarının + korpusta birebir geçmediğini de ölçtü (#579). + + KOD. Eksenler ve tohum tek bir kodda taşınıyor: `72-15-40-88-k3x9ab` + (enerji, sıcaklık, yoğunluk, hareket, tohum). Kod yazılarak aynı preset + geri getirilebiliyor. Kimlik de koddan geliyor (`md_gen1_`), yani + aynı preset iki kez kaydedilince ikinci bir kopya oluşmuyor. + + SÜRÜM. Kalıplardan biri değişirse aynı kod başka bir preset verir. O + yüzden kimlikte sürüm var ve böyle bir değişiklik VERSION'ı artırmalı: + eski sürümle kaydedilmiş presetler kendi kaynaklarını saklıyor, yeni + sürümün çıktısı onların üstüne yazılmıyor. Testlerdeki altın özetler bu + değişikliği yakalıyor. + + EKSEN OYNATMAK PRESETİ YERİNDE BIRAKIYOR. Her bileşen (palet, hareket, + her dalga ve şekil, shader, görünüm) kendi rastgele akışından çekiyor ve + çekiliş sayısı eksenlere bağlı değil. Yoğunluk bir dalga eklediğinde + paletin ve hareketin çekilişleri kaymıyor; kaydırıcı çoğunlukla aynı + presetin daha enerjik ya da daha sıcak hâlini veriyor. + + PLATFORM. Yazılan her sayı yalnız dört işlemden, min/max'tan ve + yuvarlamadan geçiyor (xorshift, doğrusal ara değer, HSL). sin, pow, exp + gibi işlevler platformlar arasında son basamakta ayrışabilir ve bir + yuvarlama sınırını çevirebilir; altın özetler Windows'ta ve Ubuntu'da + aynı çıkmalı. Açılar ve salınımlar presetin kendisinde, çalışma anında + hesaplanıyor. + + GÜVENLİK. Flaş yok: ters çevirme, solarize, parlatma ve karartma + bayrakları hep kapalı; sese bağlı hiçbir ifade basamak işlevi (above, + below, if) kullanmıyor. Renk ve saydamlık ifadeleri her girdi için [0,1] + içinde kalacak biçimde kuruluyor: MilkDrop 1'i aşan bir renk değerini + KENETLEMİYOR, SARIYOR (milkdrop.js `colorNorm`). 1,02 yazan bir ifade + siyaha atlar ve ses dalgalandıkça bu kare kare yanıp söner. Sınırlar + bu yüzden üretim anında, yazılan sayılardan hesaplanıyor. */ +(function () { + const VERSION = 1; + const AXES = ['energy', 'warmth', 'density', 'motion']; + // Tohum en çok altı hane (36^6): kod kısa kalsın + const SEED_LIMIT = 2176782336; + /* Sese bağlı q1..q3'ün tavanı. `bass_att` sessizlikte 0, ortalama müzikte + 1 civarı, sert vuruşta 3'ü aşabiliyor; preset `min` ile 2,5'te kesiyor + ve renk sınırları bu tavana göre hesaplanıyor. */ + const AUDIO_CAP = 2.5; + + // -------------------------------------------------------------------- + // Tohumlu rastgele + // -------------------------------------------------------------------- + function hashString(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; + } + + function rng(seed) { + let x = (seed >>> 0) || 1; + const next = () => { + x ^= x << 13; x >>>= 0; + x ^= x >>> 17; + x ^= x << 5; x >>>= 0; + return x / 4294967296; + }; + // İlk çıktılar tohuma çok yakın; birkaçını atmak akışı karıştırıyor + next(); next(); next(); + return next; + } + + // Bileşenin kendi akışı: ad ve tohumdan + function stream(seed, name) { + const h = hashString('mdgen' + VERSION + ':' + name); + return rng(Math.imul((h ^ seed) >>> 0, 2654435761) >>> 0); + } + + function tools(r) { + return { + r, + range: (a, b) => a + (b - a) * r(), + chance: (p) => r() < p, + int: (a, b) => a + Math.floor(r() * (b - a + 1)), + pick: (list) => list[Math.floor(r() * list.length)], + /* Ağırlıklı seçim: [değer, ağırlık] çiftleri, tek çekiliş. Sıfır ya + da eksi ağırlık o seçeneği kapatıyor. */ + pickW: (pairs) => { + let total = 0; + for (const p of pairs) total += p[1] > 0 ? p[1] : 0; + let x = r() * total; + let last = pairs[0][0]; + for (const p of pairs) { + if (!(p[1] > 0)) continue; + last = p[0]; + x -= p[1]; + if (x < 0) return p[0]; + } + return last; + }, + }; + } + + // -------------------------------------------------------------------- + // Sayılar ve ifadeler + // -------------------------------------------------------------------- + const P10 = [1, 10, 100, 1000, 10000]; + const rnd = (v, d) => Math.round(v * P10[d]) / P10[d]; + const down = (v, d) => Math.floor(v * P10[d]) / P10[d]; + const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + const lerp = (a, b, t) => a + (b - a) * t; + + /* Sayı metni YALNIZ toFixed'den: yerel ayar virgülü ve üstel gösterim + çıkmaz. "-0.000" düzeltiliyor; MilkDrop okur ama göze batar. */ + function num(v, d) { + const s = (Number.isFinite(v) ? v : 0).toFixed(d == null ? 3 : d); + return /^-0(\.0*)?$/.test(s) ? s.slice(1) : s; + } + + // `time*0.300` ya da `-time*0.300`: "time*-0.3" yazılmıyor + function tm(v, d) { + const r = rnd(v, d == null ? 3 : d); + return (r < 0 ? '-' : '') + 'time*' + num(Math.abs(r), d); + } + + /* "a + b*x - c*y" biçimli ifade. İşaret terimin önüne geçiyor ("+ -" + yazılmıyor), sıfıra yuvarlanan terim düşüyor. */ + function sum(base, terms, d) { + const dd = d == null ? 3 : d; + let s = base == null ? '' : num(rnd(base, dd), dd); + for (const [c, e] of terms) { + const v = rnd(c, dd); + if (v === 0) continue; + const t = num(Math.abs(v), dd) + '*' + e; + if (!s) s = (v < 0 ? '-' : '') + t; + else s += (v < 0 ? ' - ' : ' + ') + t; + } + return s || '0'; + } + + /* Düğüm başına ekleme: `zoom = zoom + 0.0123*rad;`. Katsayı sıfıra + yuvarlanırsa satır kendisini yazıyor, yine geçerli. */ + function grow(name, c, e, d) { + const dd = d == null ? 3 : d; + const v = rnd(c, dd); + if (v === 0) return name + ' = ' + name + ';'; + return name + ' = ' + name + (v < 0 ? ' - ' : ' + ') + num(Math.abs(v), dd) + '*' + e + ';'; + } + + // Açıya zaman terimi: `x + time*0.3` ya da `x - time*0.3` + function plusTime(expr, v, d) { + const r = rnd(v, d == null ? 3 : d); + return expr + (r < 0 ? ' - ' : ' + ') + 'time*' + num(Math.abs(r), d); + } + + /* İki değer arasında gidip gelen ifade: sin +1'de `b`, -1'de `a`. + Yazılan orta ve genlikten hesaplanıyor, yani yuvarlamadan sonra da + [0,1] dışına çıkmıyor. */ + function sway(a, b, arg) { + const mid = rnd(clamp((a + b) / 2, 0, 1), 3); + let amp = rnd((b - a) / 2, 3); + const room = Math.min(mid, 1 - mid); + if (Math.abs(amp) > room) amp = amp < 0 ? -room : room; + if (amp === 0) return num(mid); + return num(mid) + (amp < 0 ? ' - ' : ' + ') + num(Math.abs(amp)) + '*sin(' + arg + ')'; + } + + /* Sesle büyüyen değer: taban + kazanç*q, q en çok AUDIO_CAP. Kazanç + tavanı aşmayacak kadar kırpılıyor. */ + function withAudio(base, gain, q, cap) { + const b = rnd(clamp(base, 0, cap), 3); + const g = down(Math.max(0, Math.min(gain, (cap - b) / AUDIO_CAP)), 3); + return g > 0 ? num(b) + ' + ' + num(g) + '*' + q : num(b); + } + + // HSL -> [r,g,b] 0..1; yalnız dört işlem ve min/max + function hsl(h, s, l) { + const hh = ((h % 360) + 360) % 360; + const a = s * Math.min(l, 1 - l); + const f = (n) => { + const k = (n + hh / 30) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [f(0), f(8), f(4)]; + } + + const mix = (c, d, t) => [lerp(c[0], d[0], t), lerp(c[1], d[1], t), lerp(c[2], d[2], t)]; + + // -------------------------------------------------------------------- + // Eksenler, tohum ve kod + // -------------------------------------------------------------------- + function normAxes(o) { + const out = {}; + for (const k of AXES) { + const v = Math.round(Number(o && o[k])); + out[k] = Number.isFinite(v) ? clamp(v, 0, 100) : 50; + } + return out; + } + + function normSeed(s) { + const v = Math.floor(Number(s)); + return Number.isFinite(v) && v >= 0 ? v % SEED_LIMIT : 0; + } + + function encode(axes, seed) { + const a = normAxes(axes); + return [a.energy, a.warmth, a.density, a.motion].join('-') + '-' + normSeed(seed).toString(36); + } + + /* Kodu okur. Boşluk ve büyük harf kabul, baştaki sıfırlar atılıyor — + `050` ile `50` aynı kod, aynı kimlik. Aralık dışı bir eksen kodu + geçersiz kılıyor: sessizce kırpmak başka bir presete götürürdü. */ + function decode(code) { + const s = String(code == null ? '' : code).replace(/\s+/g, '').toLowerCase(); + const m = /^(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})-([0-9a-z]{1,6})$/.exec(s); + if (!m) return null; + const v = [m[1], m[2], m[3], m[4]].map(Number); + if (v.some((x) => x > 100)) return null; + return { + axes: { energy: v[0], warmth: v[1], density: v[2], motion: v[3] }, + seed: parseInt(m[5], 36), + }; + } + + const idOf = (code) => 'md_gen' + VERSION + '_' + code; + + function randomSeed(rand) { + const r = typeof rand === 'function' ? rand : Math.random; + return Math.floor(r() * SEED_LIMIT) % SEED_LIMIT; + } + + // -------------------------------------------------------------------- + // Palet + // -------------------------------------------------------------------- + /* Sıcaklık ana tonu seçiyor: 0 buz mavisi (195°), 0,5 mor (290°), + 1 turuncu (385° = 25°). Yeşil ve sarı ana ton olmuyor; bölünmüş ve + üçlü şemalarda yan ton olarak geliyorlar. Enerji doygunluğu ve şemanın + cesaretini artırıyor. */ + function makePalette(ax, T) { + const base = 195 + 190 * ax.warmth + T.range(-12, 12); + const scheme = T.pickW([ + ['komsu', 1.4 - 0.5 * ax.energy], + ['bolunmus', 0.5 + 0.6 * ax.energy], + ['uclu', 0.2 + 0.5 * ax.energy], + ]); + const offs = scheme === 'komsu' ? [0, 30, -30] : scheme === 'bolunmus' ? [0, 150, 210] : [0, 120, 240]; + const sat = clamp(0.5 + 0.42 * ax.energy + T.range(-0.08, 0.08), 0.3, 1); + const cols = []; + for (let i = 0; i < 3; i++) { + const jitter = T.range(-10, 10); + const light = T.range(0.5, 0.64); + cols.push(hsl(base + offs[i] + (i ? jitter : 0), sat, light)); + } + const dark = hsl(base + T.range(-20, 20), sat * 0.9, 0.2); + const pale = hsl(base + offs[1] * 0.5, sat * 0.45, 0.85); + return { base, scheme, cols, dark, pale }; + } + + // -------------------------------------------------------------------- + // Hareket kalıpları + // -------------------------------------------------------------------- + /* Her kalıp `frame` (kare denklemleri: temel zoom/rot/merkez) ve `pixel` + (ağ düğümü başına EKLEMELER) veriyor. İkincil kalıptan yalnız `pixel` + alınıyor ve `k` ile yarıya iniyor: iki kalıbın kare denklemleri aynı + değişkenleri yazardı ve ikincisi birincisini silerdi. + + `sp` hız çarpanı (hareket ekseni), `ge` ses kazancı (enerji ekseni). + Sessizlikte zoom 1'in altına inmiyor: tünel bir anda tersine akmasın. */ + const MOTIONS = { + tunel(ax, T, sp, ge, k) { + const z = 1.004 + 0.02 * ax.motion * T.range(0.6, 1); + const zg = 0.003 + 0.012 * ge * T.range(0.7, 1); + const r0 = (0.002 + 0.012 * ax.motion) * T.range(0.5, 1) * (T.chance(0.5) ? 1 : -1); + const f1 = sp * T.range(0.15, 0.4); + const f2 = sp * T.range(0.1, 0.3); + const f3 = sp * T.range(0.1, 0.3); + const c = (0.02 + 0.08 * ax.motion) * T.range(0.5, 1); + const kp = (0.01 + 0.04 * ax.density) * T.range(0.6, 1) * k; + return { + frame: [ + 'zoom = ' + sum(Math.max(1, z - zg), [[zg, 'q1']], 4) + ';', + 'rot = ' + sum(null, [[r0, 'sin(' + tm(f1) + ')']], 4) + ';', + 'cx = ' + sum(0.5, [[c, 'sin(' + tm(f2) + ')']]) + ';', + 'cy = ' + sum(0.5, [[c, 'cos(' + tm(f3) + ')']]) + ';', + ], + pixel: [grow('zoom', kp, 'rad', 4)], + }; + }, + girdap(ax, T, sp, ge, k) { + const s = T.chance(0.5) ? 1 : -1; + const r0 = s * (0.004 + 0.026 * ax.motion) * T.range(0.6, 1); + const rg = s * (0.002 + 0.008 * ge) * T.range(0.5, 1); + const z = 0.998 + 0.012 * T.r(); + const zg = 0.002 + 0.008 * ge * T.range(0.5, 1); + const kr = s * (0.01 + 0.05 * ax.motion) * T.range(0.5, 1) * k; + const kz = (0.005 + 0.02 * ax.density) * T.range(0.4, 1) * k; + return { + frame: [ + 'rot = ' + sum(r0, [[rg, 'q2']], 4) + ';', + 'zoom = ' + sum(Math.max(1, z - zg), [[zg, 'q1']], 4) + ';', + ], + pixel: [ + grow('rot', kr, '(1 - rad)', 4), + grow('zoom', -kz, 'rad', 4), + ], + }; + }, + halka(ax, T, sp, ge, k) { + const z = 1.0 + 0.012 * T.r(); + const zg = 0.002 + 0.01 * ge * T.range(0.5, 1); + const r = (0.002 + 0.01 * ax.motion) * T.range(0.3, 1) * (T.chance(0.5) ? 1 : -1); + const f = sp * T.range(0.1, 0.35); + const a = (0.004 + 0.016 * ge) * T.range(0.6, 1) * k; + const n = 6 + Math.floor(18 * ax.density * T.range(0.5, 1)); + const v = sp * T.range(0.8, 2.5); + return { + frame: [ + 'zoom = ' + sum(Math.max(1, z - zg), [[zg, 'q1']], 4) + ';', + 'rot = ' + sum(null, [[r, 'sin(' + tm(f) + ')']], 4) + ';', + ], + pixel: [grow('zoom', a, 'sin(rad*' + n + ' - ' + tm(v) + ')', 4)], + }; + }, + akinti(ax, T, sp, ge, k) { + const z = 1.0 + 0.006 * T.r(); + const zg = 0.001 + 0.006 * ge * T.range(0.5, 1); + const d = (0.001 + 0.004 * ax.motion) * T.range(0.4, 1); + const f1 = sp * T.range(0.08, 0.25); + const f2 = sp * T.range(0.08, 0.25); + const a = (0.002 + 0.005 * ax.motion + 0.003 * ax.energy) * T.range(0.6, 1) * k; + const fy = 3 + Math.floor(12 * ax.density * T.range(0.4, 1)); + const fx = 3 + Math.floor(12 * ax.density * T.range(0.4, 1)); + const v1 = sp * T.range(0.4, 1.4); + const v2 = sp * T.range(0.4, 1.4); + return { + frame: [ + 'zoom = ' + sum(Math.max(1, z - zg), [[zg, 'q1']], 4) + ';', + 'dx = ' + sum(null, [[d, 'sin(' + tm(f1) + ')']], 4) + ';', + 'dy = ' + sum(null, [[d, 'cos(' + tm(f2) + ')']], 4) + ';', + ], + pixel: [ + grow('dx', a, 'sin(y*' + fy + ' + ' + tm(v1) + ')', 4), + grow('dy', a, 'cos(x*' + fx + ' - ' + tm(v2) + ')', 4), + ], + }; + }, + cicek(ax, T, sp, ge, k) { + const z = 1.0 + 0.01 * T.r(); + const zg = 0.002 + 0.008 * ge * T.range(0.5, 1); + const r = (0.002 + 0.01 * ax.motion) * T.range(0.4, 1) * (T.chance(0.5) ? 1 : -1); + const f = sp * T.range(0.1, 0.3); + const n = T.pick([3, 4, 5, 6, 8]); + const a = (0.01 + 0.03 * ge) * T.range(0.5, 1) * k; + const v = sp * T.range(0.3, 1.2); + const b = (0.003 + 0.012 * ax.density) * T.range(0.3, 1) * k; + const v2 = sp * T.range(0.3, 1.0); + return { + frame: [ + 'zoom = ' + sum(Math.max(1, z - zg), [[zg, 'q1']], 4) + ';', + 'rot = ' + sum(null, [[r, 'sin(' + tm(f) + ')']], 4) + ';', + ], + pixel: [ + grow('rot', a, 'sin(ang*' + n + ' + ' + tm(v) + ')', 4), + grow('zoom', b, 'cos(ang*' + n + ' - ' + tm(v2) + ')', 4), + ], + }; + }, + nefes(ax, T, sp, ge, k) { + const a = (0.004 + 0.012 * ax.motion) * T.range(0.5, 1); + const v = sp * T.range(0.2, 0.6); + const zg = 0.002 + 0.01 * ge * T.range(0.5, 1); + const s = (0.005 + 0.02 * ax.motion) * T.range(0.3, 1); + const v2 = sp * T.range(0.1, 0.4); + const v3 = sp * T.range(0.1, 0.4); + const kz = (0.004 + 0.012 * ax.density) * T.range(0.4, 1) * k; + const n = 3 + Math.floor(9 * ax.density * T.range(0.4, 1)); + const v4 = sp * T.range(0.4, 1.4); + return { + frame: [ + 'zoom = ' + sum(1 - zg, [[a, 'sin(' + tm(v) + ')'], [zg, 'q1']], 4) + ';', + 'sx = ' + sum(1, [[s, 'sin(' + tm(v2) + ')']], 4) + ';', + 'sy = ' + sum(1, [[s, 'cos(' + tm(v3) + ')']], 4) + ';', + ], + pixel: [grow('zoom', kz, 'rad*sin(rad*' + n + ' + ' + tm(v4) + ')', 4)], + }; + }, + }; + + // Ağırlıklar eksenlerden: hangi kalıbın hangi ruha yakın olduğu + const MOTION_W = [ + ['tunel', (a) => 0.7 + 0.7 * a.motion], + ['girdap', (a) => 0.6 + 0.6 * a.energy], + ['halka', (a) => 0.5 + 0.6 * a.density], + ['akinti', (a) => 0.6 + 0.6 * (1 - a.energy)], + ['cicek', (a) => 0.5 + 0.5 * a.density], + ['nefes', (a) => 0.5 + 0.7 * (1 - a.motion)], + ]; + + function buildMotion(ax, seed, sp, ge) { + const T = tools(stream(seed, 'motion')); + const primary = T.pickW(MOTION_W.map(([n, f]) => [n, f(ax)])); + const wantSecond = T.r() < 0.2 + 0.6 * ax.density; + const second = T.pickW(MOTION_W.filter(([n]) => n !== primary).map(([n, f]) => [n, f(ax)])); + const P = MOTIONS[primary](ax, T, sp, ge, 1); + /* İkincil kalıp KENDİ akışından: birincil değişince ikincilin + sayıları kaymasın, ve tersi. */ + const S = MOTIONS[second](ax, tools(stream(seed, 'motion2')), sp, ge, 0.5); + const warp0 = T.range(0, 0.35) + 0.5 * ax.density * T.r(); + const warpG = (0.05 + 0.25 * ax.energy) * T.r(); + return { + primary, + second: wantSecond ? second : null, + frame: P.frame.concat(['warp = ' + sum(warp0, [[warpG, 'q3']]) + ';']), + pixel: P.pixel.concat(wantSecond ? S.pixel : []), + }; + } + + // -------------------------------------------------------------------- + // Özel dalgalar + // -------------------------------------------------------------------- + const WAVE_KINDS = [ + ['halka', (a) => 1.0], + ['sarmal', (a) => 0.45 + 0.6 * a.motion], + ['ufuk', (a) => 0.6 + 0.4 * a.energy], + ['lissajous', (a) => 0.5 + 0.5 * a.density], + ['yorunge', (a) => 0.35 + 0.6 * a.density], + ['yildiz', (a) => 0.35 + 0.6 * a.energy], + ]; + // k. dalganın açık olma olasılığı; yoğunlukla artıyor + const WAVE_ON = [(d) => 0.35 + 0.6 * d, (d) => 0.1 + 0.55 * d, (d) => -0.1 + 0.6 * d, (d) => -0.35 + 0.7 * d]; + + /* Konum `q9`/`q10` ile çarpılıyor: kare denklemleri onları 1/aspecty ve + 1/aspectx yapıyor, halka geniş ekranda da yuvarlak kalıyor. Dalga + kendi makinesinde aspectx'i göremiyor (MilkDrop 2 onu alt bloklara + taşımıyor), q değişkenlerini görüyor. */ + function buildWave(k, ax, seed, pal, sp, ge, addScale, force) { + const T = tools(stream(seed, 'wave' + k)); + // Çekiliş zorlansa da yapılıyor: akışın geri kalanı aynı yerden sürsün + if (!(T.r() < WAVE_ON[k](ax.density)) && !force) return null; + const kind = T.pickW(WAVE_KINDS.map(([n, f]) => [n, f(ax)])); + const cA = pal.cols[k % 3]; + const cB = pal.cols[(k + 1) % 3]; + const sign = T.chance(0.5) ? 1 : -1; + const v1 = sign * sp * T.range(0.15, 0.6) * (kind === 'ufuk' ? 0.35 : 1); + const v2 = sp * T.range(0.2, 0.7); + const vc = sp * T.range(0.2, 0.9); + const nc = T.int(1, 4); + const additive = T.chance(0.6); + const thick = T.chance(0.3 + 0.4 * ax.density); + const a0 = clamp(0.18 + 0.3 * T.r(), 0.15, 0.5) * (additive ? addScale : 1); + const ag = (0.03 + 0.15 * ax.energy) * T.r() * (additive ? addScale : 1); + const scaling = lerp(0.6, 1.8, ax.energy) * T.range(0.8, 1.2); + const smoothing = T.range(0.3, 0.85); + const frame = ['t1 = ' + tm(v1) + ';', 't2 = ' + tm(v2) + ';']; + const pt = []; + const place = (xe, ye) => { + pt.push('x = 0.5 + (' + xe + ')*q9;'); + pt.push('y = 0.5 + (' + ye + ')*q10;'); + }; + let samples = 512; + let dots = false; + let spectrum = false; + if (kind === 'halka') { + const r0 = T.range(0.12, 0.34); + const rv = T.range(0.04, 0.16) * (0.4 + 0.6 * ge); + /* Zamanla dalgalanan kenar: düz bir halkayı döndürmek görüntüyü + değiştirmiyor, sessizlikte preset donmuş görünüyordu. */ + const rw = T.range(0.01, 0.05); + const nw = T.int(2, 6); + spectrum = T.chance(0.3); + pt.push('t3 = sample*6.2832;'); + pt.push('t4 = ' + sum(r0, [[rv, 'value1'], [rw, 'sin(t3*' + nw + ' + t2)']]) + ';'); + place('t4*cos(t3 + t1)', 't4*sin(t3 + t1)'); + } else if (kind === 'sarmal') { + const turns = T.int(2, 5); + const R = T.range(0.3, 0.46); + const kv = T.range(0.1, 0.35); + pt.push('t3 = sample*' + num(6.2832 * turns, 4) + ';'); + pt.push('t4 = sample*' + num(R) + '*(1 + ' + num(kv) + '*value1);'); + place('t4*cos(t3 + t1)', 't4*sin(t3 + t1)'); + } else if (kind === 'ufuk') { + const len = T.range(0.6, 1.0); + const amp = T.range(0.08, 0.22) * (0.5 + 0.6 * ge); + spectrum = T.chance(0.5); + samples = T.pick([256, 384, 512]); + pt.push('t3 = ' + sum(-len / 2, [[len, 'sample']]) + ';'); + pt.push('t4 = ' + num(amp) + '*value1;'); + place('t3*cos(t1) - t4*sin(t1)', 't3*sin(t1) + t4*cos(t1)'); + } else if (kind === 'lissajous') { + const fa = T.int(1, 4); + const fb = fa + T.int(1, 3); + const A = T.range(0.2, 0.38); + const B = T.range(0.2, 0.38); + const kv = T.range(0.05, 0.25); + pt.push('t3 = sample*6.2832;'); + place(num(A) + '*sin(t3*' + fa + ' + t1)*(1 + ' + num(kv) + '*value1)', + num(B) + '*sin(t3*' + fb + ' + t2)*(1 + ' + num(kv) + '*value2)'); + } else if (kind === 'yorunge') { + const n = T.int(1, 3); + const m = T.pick([1, 2, 3]); + const r0 = T.range(0.15, 0.38); + const kv = T.range(0.03, 0.12); + samples = T.int(24, 72); + dots = true; + pt.push('t3 = sample*' + num(6.2832 * n, 4) + ';'); + pt.push('t4 = ' + sum(r0, [[kv, 'value1']]) + ';'); + place('t4*cos(t3 + t1)', 't4*sin(t3*' + m + ' + t1)'); + } else { + const n = T.int(3, 9); + const r0 = T.range(0.12, 0.3); + const kd = T.range(0.2, 0.6); + const ke = T.range(0.03, 0.12) * (0.4 + 0.6 * ge); + pt.push('t3 = sample*6.2832;'); + pt.push('t4 = ' + num(r0) + '*(1 + ' + num(kd) + '*sin(t3*' + n + ' + t2)) + ' + num(ke) + '*value1;'); + place('t4*cos(t3 + t1)', 't4*sin(t3 + t1)'); + } + // Renk dalga boyunca iki palet rengi arasında akıyor + const arg = 'sample*' + num(6.2832 * nc, 4) + ' + ' + tm(vc); + pt.push('r = ' + sway(cA[0], cB[0], arg) + ';'); + pt.push('g = ' + sway(cA[1], cB[1], arg) + ';'); + pt.push('b = ' + sway(cA[2], cB[2], arg) + ';'); + pt.push('a = ' + withAudio(a0, ag, 'q1', 0.95) + ';'); + return { + kind, + additive, + head: [ + ['enabled', '1'], + ['samples', String(samples)], + ['sep', '0'], + ['bSpectrum', spectrum ? '1' : '0'], + ['bUseDots', dots ? '1' : '0'], + ['bDrawThick', thick || dots ? '1' : '0'], + ['bAdditive', additive ? '1' : '0'], + ['scaling', num(scaling)], + ['smoothing', num(smoothing)], + ['r', num(cA[0])], ['g', num(cA[1])], ['b', num(cA[2])], ['a', num(a0)], + ], + frame, + point: pt, + }; + } + + // -------------------------------------------------------------------- + // Özel şekiller + // -------------------------------------------------------------------- + const SHAPE_KINDS = [ + ['cekirdek', (a) => 1.0], + ['yorungeler', (a) => 0.4 + 0.9 * a.density], + ['cerceve', (a) => 0.55 + 0.35 * a.motion], + ['ayna', (a) => 0.3 + 0.5 * a.density], + ]; + const SHAPE_ON = [(d) => 0.4 + 0.5 * d, (d) => 0.12 + 0.5 * d, (d) => -0.1 + 0.55 * d, (d) => -0.3 + 0.6 * d]; + + function buildShape(k, ax, seed, pal, sp, ge, addScale) { + const T = tools(stream(seed, 'shape' + k)); + if (!(T.r() < SHAPE_ON[k](ax.density))) return null; + const kind = T.pickW(SHAPE_KINDS.map(([n, f]) => [n, f(ax)])); + const C = pal.cols[(k + 2) % 3]; + const D = pal.cols[k % 3]; + const sign = T.chance(0.5) ? 1 : -1; + const w = sign * sp * T.range(0.1, 0.5); + const vc = sp * T.range(0.15, 0.6); + const recolor = T.chance(0.6); + const carg = tm(vc); + const head = { + sides: 4, additive: 0, thickOutline: 0, textured: 0, num_inst: 1, + x: 0.5, y: 0.5, rad: 0.1, ang: 0, tex_ang: 0, tex_zoom: 1, + r: C[0], g: C[1], b: C[2], a: 0.2, + r2: D[0], g2: D[1], b2: D[2], a2: 0, + border_r: pal.pale[0], border_g: pal.pale[1], border_b: pal.pale[2], border_a: 0, + }; + const fr = []; + const colorLines = (pre, x, y) => { + if (!recolor) return; + fr.push(pre + 'r = ' + sway(x[0], y[0], carg) + ';'); + fr.push(pre + 'g = ' + sway(x[1], y[1], carg) + ';'); + fr.push(pre + 'b = ' + sway(x[2], y[2], carg) + ';'); + }; + if (kind === 'cekirdek') { + const r0 = T.range(0.05, 0.14); + const rg = T.range(0.01, 0.05) * (0.3 + ge); + // Sessizlikte de nefes alsın: yuvarlak bir çekirdeği döndürmek görünmüyor + const rw = T.range(0.005, 0.03); + const vr = sp * T.range(0.3, 1.0); + const a0 = T.range(0.08, 0.25) * addScale; + const ag = (0.03 + 0.12 * ax.energy) * T.r() * addScale; + const b0 = T.range(0.05, 0.2); + const bg = (0.02 + 0.1 * ax.energy) * T.r(); + head.sides = T.pick([3, 4, 5, 6, 8, 32, 48]); + head.additive = 1; + head.rad = r0; + head.a = a0; + head.border_a = b0; + // Yarıçap 0,4'ü aşmıyor: taban + salınım + en yüksek seste kazanç + const rgc = down(Math.max(0, Math.min(rg, (0.4 - rnd(r0, 3) - rnd(rw, 3)) / AUDIO_CAP)), 3); + fr.push('rad = ' + sum(r0, [[rw, 'sin(' + tm(vr) + ')'], [rgc, 'q1']]) + ';'); + fr.push('ang = ' + tm(w) + ';'); + fr.push('a = ' + withAudio(a0, ag, 'q1', 0.9) + ';'); + fr.push('border_a = ' + withAudio(b0, bg, 'q3', 0.6) + ';'); + colorLines('', C, D); + } else if (kind === 'yorungeler') { + const n = T.int(2, 3 + Math.floor(9 * ax.density)); + const R = T.range(0.18, 0.38); + const r0 = T.range(0.02, 0.07); + const rg = T.range(0.005, 0.03) * (0.3 + ge); + const spin = T.range(0.5, 2); + const additive = T.chance(0.7); + const a0 = T.range(0.15, 0.4) * (additive ? addScale : 1); + const ag = (0.02 + 0.1 * ax.energy) * T.r() * (additive ? addScale : 1); + head.sides = T.pick([3, 4, 5, 6, 24]); + head.additive = additive ? 1 : 0; + head.thickOutline = T.chance(0.4) ? 1 : 0; + head.num_inst = n; + head.rad = r0; + head.a = a0; + head.a2 = a0 * 0.3; + head.border_a = T.range(0.1, 0.35); + fr.push('ph = ' + plusTime(num(rnd(6.2832 / n, 4), 4) + '*instance', w) + ';'); + fr.push('x = 0.5 + ' + num(R) + '*cos(ph)*q9;'); + fr.push('y = 0.5 + ' + num(R) + '*sin(ph)*q10;'); + fr.push('rad = ' + withAudio(r0, rg, 'q2', 0.2) + ';'); + fr.push('ang = ph*' + num(spin) + ';'); + fr.push('a = ' + withAudio(a0, ag, 'q2', 0.9) + ';'); + colorLines('', C, D); + } else if (kind === 'cerceve') { + const R = T.range(0.25, 0.45); + const rv = T.range(0.01, 0.05); + const f = sp * T.range(0.2, 0.8); + const b0 = T.range(0.1, 0.3); + const bg = (0.02 + 0.12 * ax.energy) * T.r(); + head.sides = T.int(3, 8); + head.additive = T.chance(0.5) ? 1 : 0; + head.thickOutline = 1; + head.rad = R; + head.a = 0; + head.border_r = C[0]; head.border_g = C[1]; head.border_b = C[2]; + head.border_a = b0; + fr.push('rad = ' + sum(R, [[rv, 'sin(' + tm(f) + ')']]) + ';'); + fr.push('ang = ' + tm(w) + ';'); + fr.push('border_a = ' + withAudio(b0, bg, 'q2', 0.7) + ';'); + colorLines('border_', C, D); + } else { + /* Dokulu şekil bir önceki kareyi kendi içine, döndürülmüş ve + ölçeklenmiş olarak çiziyor; geri beslemeyle iç içe desenler + doğuyor. Saydam ve toplamasız: parlaklığı kareden kareye + katlanmıyor. */ + const R = T.range(0.28, 0.5); + const a0 = T.range(0.25, 0.55); + const tz = T.range(0.75, 1.3); + const tzv = T.range(0.02, 0.12); + const f = sp * T.range(0.1, 0.4); + const w2 = sp * T.range(0.05, 0.3) * (T.chance(0.5) ? 1 : -1); + const tint = mix(pal.pale, [1, 1, 1], T.range(0.3, 0.8)); + head.sides = T.pick([4, 5, 6, 8, 40]); + head.textured = 1; + head.rad = R; + head.r = tint[0]; head.g = tint[1]; head.b = tint[2]; head.a = a0; + head.r2 = tint[0]; head.g2 = tint[1]; head.b2 = tint[2]; head.a2 = a0 * T.range(0.3, 1); + head.tex_zoom = tz; + fr.push('ang = ' + tm(w * 0.5) + ';'); + fr.push('tex_ang = ' + tm(w2) + ';'); + fr.push('tex_zoom = ' + sum(tz, [[tzv, 'sin(' + tm(f) + ')']]) + ';'); + } + return { kind, additive: head.additive === 1, head, frame: fr }; + } + + // -------------------------------------------------------------------- + // Shader'lar (MilkDrop 2) + // -------------------------------------------------------------------- + /* Warp shader'ı olan presette MilkDrop `decay`i KENDİSİ uygulamıyor; + söndürmek shader'ın işi. `q8` kare denklemlerinde saniyelik sönmeyi + kare hızından bağımsız kılıyor: 30 fps'te `decay`in kendisi, 60'ta + karekökü. */ + const COMP_KINDS = [ + ['parilti', (a) => 1.0], + ['ton', (a) => 0.7 + 0.4 * a.energy], + ['ayna', (a) => 0.4 + 0.4 * a.density], + ['kabartma', (a) => 0.4 + 0.3 * (1 - a.density)], + ]; + const WARP_KINDS = [ + ['duman', (a) => 1.0], + ['turbulans', (a) => 0.6 + 0.6 * a.motion], + ]; + + function buildShaders(ax, T, sp) { + const style = T.pickW([ + ['yok', 1.0], + ['comp', 1.1], + ['warp', 0.3 + 0.3 * ax.density], + ['ikisi', 0.4 + 0.6 * ax.density], + ]); + const compKind = T.pickW(COMP_KINDS.map(([n, f]) => [n, f(ax)])); + const warpKind = T.pickW(WARP_KINDS.map(([n, f]) => [n, f(ax)])); + // Sayılar koşulsuz çekiliyor: seçim değişince diğerleri kaymasın + const gain = T.range(1.05, 1.5); + const k1 = T.range(0.9, 1.2); + const k2 = T.range(0.3, 0.8); + const vig = T.range(0.3, 1.2); + const tone = T.range(0.3, 0.8); + const fourWay = T.chance(0.4); + /* Kenar vurgusu hareketli ince çizgiyi de güçlendiriyor: hızlı bir + Lissajous'un geçtiği ekran parçası saniyede üç kez aydınlanıp + kararıyordu (ölçüm: 200 presetin en kötüsü). Vurgu ve kazanç düşük. */ + const edge = T.range(0.4, 1.0); + const blurMix = T.range(0.05, 0.3); + /* Warp shader'ının sabit çıkarması: 8 bitlik tamponda çarpımsal sönme + küçük değerleri sıfıra indiremiyor (3/255 * 0,97 yine 3/255'e + yuvarlanıyor), yarım adımdan büyük bir çıkarma iz bırakmıyor. Daha + büyüğü soluk öğeleri yiyordu (ölçüm: 0,0028'de ekranın %3'ü). */ + const sub = T.range(0.0012, 0.002); + const ns = T.range(0.3, 1.2); + const nv = sp * T.range(0.01, 0.04); + const na = T.range(0.002, 0.008); + + let comp = null; + if (style === 'comp' || style === 'ikisi') { + if (compKind === 'parilti') { + comp = [ + 'float3 c = tex2D(sampler_main, uv).xyz;', + 'float3 g = GetBlur1(uv);', + 'ret = c*' + num(k1) + ' + g*' + num(k2) + ';', + 'ret *= 1 - ' + num(vig) + '*dot(uv - 0.5, uv - 0.5);', + ]; + } else if (compKind === 'ton') { + comp = [ + 'float3 c = tex2D(sampler_main, uv).xyz;', + 'ret = lerp(c, c.yzx, q4*' + num(tone) + ')*' + num(gain) + ';', + ]; + } else if (compKind === 'ayna') { + comp = [ + 'float2 m = uv;', + 'm.x = 0.5 - abs(m.x - 0.5);', + ].concat(fourWay ? ['m.y = 0.5 - abs(m.y - 0.5);'] : []).concat([ + 'ret = tex2D(sampler_main, m).xyz*' + num(k1) + ' + GetBlur1(m)*' + num(k2 * 0.6) + ';', + ]); + } else { + comp = [ + 'float3 c = tex2D(sampler_main, uv).xyz;', + 'float3 b = GetBlur1(uv);', + 'ret = (c + (c - b)*' + num(edge) + ')*' + num(Math.min(gain, 1.25)) + ';', + ]; + } + } + let warp = null; + if (style === 'warp' || style === 'ikisi') { + if (warpKind === 'duman') { + warp = [ + 'float3 c = tex2D(sampler_main, uv).xyz;', + 'float3 b = GetBlur1(uv);', + 'ret = lerp(c, b, ' + num(blurMix) + ')*q8 - ' + num(sub, 4) + ';', + ]; + } else { + warp = [ + 'float2 n = tex2D(sampler_noise_lq, uv*' + num(ns) + ' + time*' + num(nv, 4) + ').xy - 0.5;', + 'ret = tex2D(sampler_main, uv + n*' + num(na, 4) + ').xyz*q8 - ' + num(sub, 4) + ';', + ]; + } + } + return { + style, + comp, + warp, + compKind: comp ? compKind : null, + warpKind: warp ? warpKind : null, + }; + } + + // -------------------------------------------------------------------- + // Ad + // -------------------------------------------------------------------- + /* Ad yalnız arayüz dilinde üretiliyor ve KAYNAĞA girmiyor: aynı kod iki + dilde aynı .milk'i veriyor. Presetin adı onun kendi adı; İngilizce + arayüzde üretilen preset İngilizce adla kaydediliyor. */ + const NAME_WORDS = { + tr: { + tone: [['Buzlu', 'Soğuk', 'Kristal'], ['Lacivert', 'Derin', 'Gece Mavisi'], ['Mor', 'Leylak', 'Eflatun'], + ['Pembe', 'Mercan', 'Fuşya'], ['Kızıl', 'Kehribar', 'Altın']], + mood: [['Dingin', 'Sakin', 'Uykulu'], ['Akışkan', 'Salınan', 'Gezgin'], ['Coşkun', 'Çılgın', 'Fırtınalı']], + noun: { + tunel: ['Tünel', 'Geçit'], girdap: ['Girdap', 'Anafor'], halka: ['Halkalar', 'Yankı'], + akinti: ['Akıntı', 'Rüzgâr'], cicek: ['Çiçek', 'Taç'], nefes: ['Nefes', 'Nabız'], + }, + }, + en: { + tone: [['Icy', 'Cold', 'Crystal'], ['Navy', 'Deep', 'Midnight'], ['Violet', 'Lilac', 'Mauve'], + ['Rose', 'Coral', 'Fuchsia'], ['Crimson', 'Amber', 'Golden']], + mood: [['Still', 'Calm', 'Drowsy'], ['Fluid', 'Swaying', 'Wandering'], ['Restless', 'Wild', 'Stormy']], + noun: { + tunel: ['Tunnel', 'Passage'], girdap: ['Vortex', 'Whirl'], halka: ['Rings', 'Echo'], + akinti: ['Current', 'Drift'], cicek: ['Bloom', 'Crown'], nefes: ['Breath', 'Pulse'], + }, + }, + }; + + function makeName(axes, seed, motion, lang) { + const W = NAME_WORDS[lang === 'en' ? 'en' : 'tr']; + const T = tools(stream(seed, 'name')); + const useTone = T.chance(0.5); + const ti = T.int(0, 2); + const mi = T.int(0, 2); + const ni = T.int(0, 1); + const tb = axes.warmth <= 19 ? 0 : axes.warmth <= 39 ? 1 : axes.warmth <= 59 ? 2 : axes.warmth <= 79 ? 3 : 4; + const mb = axes.energy <= 33 ? 0 : axes.energy <= 66 ? 1 : 2; + const adj = useTone ? W.tone[tb][ti] : W.mood[mb][mi]; + return adj + ' ' + W.noun[motion][ni] + ' · ' + normSeed(seed).toString(36); + } + + // -------------------------------------------------------------------- + // Preset + // -------------------------------------------------------------------- + /* Ana dalganın kipi. 1, 2, 3 ve 5 sol/sağ kanalı birbirine karşı + çiziyor: sessizlikte tek bir noktaya, müzikte küçük bir kümeye + iniyorlar. Ağırlıkları yoğunlukla açılıyor ve en az iki öğe yoksa + kapalı (ölçüm: tek bir çekirdekle kalan preset müzikte ekranın %4'ünü + aydınlatıyordu). 0, 4, 6 ve 7 sessizlikte de bir çember ya da ekranı + geçen bir çizgi bırakıyor. */ + const MAIN_MODES = [ + [0, () => 1.0], [1, (a, n) => (n >= 2 ? 0.6 * a.density : 0)], [2, (a, n) => (n >= 2 ? 0.6 * a.density : 0)], + [3, (a, n) => (n >= 2 ? 0.5 * a.density : 0)], [4, () => 0.5], + [5, (a, n) => (n >= 2 ? (0.25 + 0.5 * a.energy) * a.density : 0)], [6, () => 0.5], [7, () => 0.45], + ]; + + /* Üretir. `opts`: { energy, warmth, density, motion } (0..100 tamsayı, + `axes` altında da verilebilir), `seed` ve `lang` ('tr' | 'en'; yalnız + adı etkiliyor). Dönüş: kimlik, kod, ad, kaynak ve arayüzün + gösterebileceği özet (`parts`). */ + function generate(opts) { + const o = opts || {}; + const axes = normAxes(o.axes || o); + const seed = normSeed(o.seed); + const code = encode(axes, seed); + const ax = { + energy: axes.energy / 100, + warmth: axes.warmth / 100, + density: axes.density / 100, + motion: axes.motion / 100, + }; + const sp = lerp(0.25, 1.6, ax.motion); + const ge = lerp(0.15, 1.2, ax.energy); + + const pal = makePalette(ax, tools(stream(seed, 'palette'))); + const motion = buildMotion(ax, seed, sp, ge); + + /* Toplamalı öğelerin saydamlığı öğe sayısıyla azalıyor: yoğun bir + presette dört toplamalı dalga ve şekil üst üste binip beyaza + doymasın. Sayım, öğelerin "açık mı" çekilişinden; o çekiliş her + akışın ilki olduğu için burada yeniden yapılabiliyor. */ + let count = 0; + for (let k = 0; k < 4; k++) { + if (stream(seed, 'wave' + k)() < WAVE_ON[k](ax.density)) count++; + if (stream(seed, 'shape' + k)() < SHAPE_ON[k](ax.density)) count++; + } + /* Hiç öğe çıkmadıysa ilk dalga yine de açılıyor. Yalnız ana dalgayla + kalan seyrek presetler ölçümde siyaha yakın çıktı: ana dalga ince + bir çizgi, bazı kiplerde sessizlikte tek bir nokta. Seyrek, boş + demek değil. */ + const forceWave = count === 0; + if (forceWave) count = 1; + const addScale = 1 / (1 + 0.18 * count); + const waves = []; + const shapes = []; + for (let k = 0; k < 4; k++) { + const w = buildWave(k, ax, seed, pal, sp, ge, addScale, forceWave && k === 0); + if (w) waves.push([k, w]); + const s = buildShape(k, ax, seed, pal, sp, ge, addScale); + if (s) shapes.push([k, s]); + } + const sh = buildShaders(ax, tools(stream(seed, 'shader')), sp); + + // Görünüm: sönme, gama, yankı, kenarlar, vektörler, ana dalga + const L = tools(stream(seed, 'look')); + /* Üst sınır 0,98: 0,99'da aynı yere her kare çizilen kalın, toplamalı + bir çizgi sessizlikte bile birikip akışla bütün ekrana yayılıyordu + (ölçüm: ekranın %61'i beyaz). */ + const decay = clamp(0.972 - 0.035 * ax.density + 0.01 * ax.motion + L.range(-0.006, 0.006), 0.925, 0.98); + /* Seyrek presette gama daha yüksek (az öğe, daha parlak), ama 1,9'a + kadar çıkınca tünel gibi biriken hareketlerde yüksek seste beyaza + doyuyordu (ölçüm: 200 presetin birinde ekranın %37'si). */ + const gamma = clamp(lerp(1.7, 1.25, ax.density) + L.range(-0.1, 0.1), 1.1, 1.8); + const echoOn = L.chance(0.45); + const echoA = L.range(0.15, 0.4); + const echoZ = L.range(0.985, 1.035); + const echoO = L.int(0, 3); + /* Ortayı karartmak ortada biriken parlaklığı kesiyor, ama seyrek bir + presette her şey zaten ortada: orada tek öğeyi de siliyordu (ölçüm: + yoğunluk 0'da siyah kalan preset). */ + const darkCenter = L.chance(motion.primary === 'tunel' ? 0.5 : 0.2) && ax.density >= 0.4; + const texWrap = L.chance(0.8); + const warpSpeed = lerp(0.4, 2.0, ax.motion) * L.range(0.8, 1.2); + const warpScale = lerp(0.7, 2.4, ax.density) * L.range(0.8, 1.2); + const zexpOn = L.chance(0.25); + const zexp = L.range(0.8, 1.35); + const hueOn = L.chance(0.3); + const hue = L.range(0.2, 0.7); + const obOn = L.chance(0.4); + const ob = [L.range(0.004, 0.02), L.range(0.1, 0.4)]; + const ibOn = L.chance(0.35); + const ib = [L.range(0.004, 0.02), L.range(0.05, 0.2)]; + const mvOn = L.chance(0.1 + 0.2 * ax.density); + const mvX = L.int(12, 40); + const mvL = L.range(0.5, 2); + const mvA = L.range(0.05, 0.18); + + const V = tools(stream(seed, 'mainwave')); + const mode = V.pickW(MAIN_MODES.map(([n, f]) => [n, f(ax, count)])); + const wAdd = V.chance(0.55); + /* Seyrek presette ana dalga çoğunlukla kalın: çoğu zaman ekrandaki tek + çizgi o. Noktalı dalga yalnız başka öğeler varken — tek başına + noktalar küçültülünce kayboluyor (ölçüm: her seste siyah çıkan + preset). */ + const wThick = V.chance(0.8 - 0.45 * ax.density); + const wDots = V.chance(0.08) && count >= 2; + const wMax = V.chance(0.2); + /* Ana dalga her kare çiziliyor ve sönme ne kadar yavaşsa o kadar + birikiyor: saydamlık (1 - sönme) ile ölçekleniyor, 0,97'de tam. */ + const wA = clamp(0.35 + 0.45 * ax.energy + V.range(-0.05, 0.05), 0.3, 0.9) * + (wAdd ? Math.max(0.6, addScale) : 1) * clamp((1 - decay) / 0.03, 0.5, 1); + const wScale = lerp(0.6, 1.6, ax.energy) * V.range(0.85, 1.15); + const wSmooth = V.range(0.45, 0.85); + const wParam = V.range(-0.4, 0.4); + const wv = sp * V.range(0.1, 0.4); + + const cW = pal.cols[0]; + const cW2 = pal.cols[1]; + const warpShader = !!sh.warp; + const lines = []; + const put = (k, v) => lines.push(k + '=' + v); + if (sh.warp || sh.comp) { + put('MILKDROP_PRESET_VERSION', '201'); + put('PSVERSION', '2'); + put('PSVERSION_WARP', sh.warp ? '2' : '0'); + put('PSVERSION_COMP', sh.comp ? '2' : '0'); + } + lines.push('[preset00]'); + put('fRating', '3.000'); + put('fGammaAdj', num(gamma)); + put('fDecay', num(decay)); + put('fVideoEchoZoom', num(echoOn ? echoZ : 1)); + put('fVideoEchoAlpha', num(echoOn ? echoA : 0)); + put('nVideoEchoOrientation', String(echoO)); + put('nWaveMode', String(mode)); + put('bAdditiveWaves', wAdd ? '1' : '0'); + put('bWaveDots', wDots ? '1' : '0'); + put('bWaveThick', wThick ? '1' : '0'); + put('bModWaveAlphaByVolume', '0'); + put('bMaximizeWaveColor', wMax ? '1' : '0'); + put('bTexWrap', texWrap ? '1' : '0'); + put('bDarkenCenter', darkCenter ? '1' : '0'); + // Flaşa götürebilecek dört bayrak hep kapalı + put('bBrighten', '0'); + put('bDarken', '0'); + put('bSolarize', '0'); + put('bInvert', '0'); + put('fModWaveAlphaStart', '0.750'); + put('fModWaveAlphaEnd', '0.950'); + put('fWarpAnimSpeed', num(warpSpeed)); + put('fWarpScale', num(warpScale)); + put('fZoomExponent', num(zexpOn ? zexp : 1)); + put('fShader', num(hueOn ? hue : 0)); + put('zoom', '1.000'); + put('rot', '0.000'); + put('cx', '0.500'); + put('cy', '0.500'); + put('dx', '0.000'); + put('dy', '0.000'); + put('warp', '0.300'); + put('sx', '1.000'); + put('sy', '1.000'); + put('wave_r', num(cW[0])); + put('wave_g', num(cW[1])); + put('wave_b', num(cW[2])); + put('wave_a', num(wA)); + put('wave_x', '0.500'); + put('wave_y', '0.500'); + put('fWaveScale', num(wScale)); + put('fWaveSmoothing', num(wSmooth)); + put('fWaveParam', num(wParam)); + put('ob_size', num(ob[0])); + put('ob_r', num(pal.dark[0])); + put('ob_g', num(pal.dark[1])); + put('ob_b', num(pal.dark[2])); + put('ob_a', num(obOn ? ob[1] : 0)); + put('ib_size', num(ib[0])); + put('ib_r', num(pal.cols[2][0])); + put('ib_g', num(pal.cols[2][1])); + put('ib_b', num(pal.cols[2][2])); + put('ib_a', num(ibOn ? ib[1] : 0)); + put('nMotionVectorsX', num(mvOn ? mvX : 0)); + put('nMotionVectorsY', num(mvOn ? Math.round(mvX * 0.75) : 0)); + put('mv_dx', '0.000'); + put('mv_dy', '0.000'); + put('mv_l', num(mvL)); + put('mv_r', num(pal.cols[1][0])); + put('mv_g', num(pal.cols[1][1])); + put('mv_b', num(pal.cols[1][2])); + put('mv_a', num(mvOn ? mvA : 0)); + + const frame = [ + 'q1 = min(bass_att, ' + num(AUDIO_CAP, 1) + ');', + 'q2 = min(mid_att, ' + num(AUDIO_CAP, 1) + ');', + 'q3 = min(treb_att, ' + num(AUDIO_CAP, 1) + ');', + 'q4 = 0.5 + 0.5*sin(' + tm(sp * 0.07) + ');', + 'q9 = 1/aspecty;', + 'q10 = 1/aspectx;', + ]; + if (warpShader) frame.push('q8 = pow(' + num(decay) + ', 30/max(fps, 15));'); + for (const l of motion.frame) frame.push(l); + const carg = tm(wv); + frame.push('wave_r = ' + sway(cW[0], cW2[0], carg) + ';'); + frame.push('wave_g = ' + sway(cW[1], cW2[1], carg) + ';'); + frame.push('wave_b = ' + sway(cW[2], cW2[2], carg) + ';'); + frame.forEach((l, i) => put('per_frame_' + (i + 1), l)); + motion.pixel.forEach((l, i) => put('per_pixel_' + (i + 1), l)); + + for (const [k, w] of waves) { + for (const [key, v] of w.head) put('wavecode_' + k + '_' + key, v); + w.frame.forEach((l, i) => put('wave_' + k + '_per_frame' + (i + 1), l)); + w.point.forEach((l, i) => put('wave_' + k + '_per_point' + (i + 1), l)); + } + const SHAPE_HEAD = ['sides', 'additive', 'thickOutline', 'textured', 'num_inst', 'x', 'y', 'rad', 'ang', + 'tex_ang', 'tex_zoom', 'r', 'g', 'b', 'a', 'r2', 'g2', 'b2', 'a2', 'border_r', 'border_g', 'border_b', 'border_a']; + const INT_HEAD = ['sides', 'additive', 'thickOutline', 'textured', 'num_inst']; + for (const [k, s] of shapes) { + put('shapecode_' + k + '_enabled', '1'); + for (const key of SHAPE_HEAD) { + const v = s.head[key]; + put('shapecode_' + k + '_' + key, INT_HEAD.indexOf(key) >= 0 ? String(v) : num(v)); + } + s.frame.forEach((l, i) => put('shape_' + k + '_per_frame' + (i + 1), l)); + } + /* Shader satırları MilkDrop 2'nin biçiminde: her satırın başında ters + tırnak, gövde `shader_body { ... }`. */ + const shaderLines = (prefix, body) => { + const out = ['shader_body', '{'].concat(body.map((l) => ' ' + l)).concat(['}']); + out.forEach((l, i) => put(prefix + (i + 1), '`' + l)); + }; + if (sh.warp) shaderLines('warp_', sh.warp); + if (sh.comp) shaderLines('comp_', sh.comp); + + const name = makeName(axes, seed, motion.primary, o.lang); + return { + id: idOf(code), + code, + name, + source: lines.join('\n') + '\n', + axes, + seed, + parts: { + motion: motion.primary, + motion2: motion.second, + waves: waves.map(([, w]) => w.kind), + shapes: shapes.map(([, s]) => s.kind), + shader: sh.style, + comp: sh.compKind, + warp: sh.warpKind, + palette: pal.scheme, + }, + }; + } + + const api = { + VERSION, AXES, SEED_LIMIT, AUDIO_CAP, + generate, encode, decode, idOf, randomSeed, normAxes, + /* Sınır yardımcıları testler için: bugünkü aralıklarda sınırları hiç + zorlanmıyor, yani üretilen presetlerden sınanamıyorlar. Aralık + genişletilince devreye girecekler ve o zaman da tutmaları gerek. */ + _test: { num, sway, withAudio }, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (typeof window !== 'undefined') window.SVMdGen = api; +})(); diff --git a/tests/aspect-i18n.test.js b/tests/aspect-i18n.test.js index 318881d..a5015fb 100644 --- a/tests/aspect-i18n.test.js +++ b/tests/aspect-i18n.test.js @@ -74,6 +74,8 @@ const PANELS = [ doku paketi. Duman testinin tarayabilmesi için panelin AÇIK olması gerekiyor; kaynaktan denetlemek o koşula bağlı değil. */ 'src/admin/milkdrop-panel.js', + // MilkDrop preset üreticisi (#579): panel yalnız kart açıkken çiziliyor + 'src/admin/milkdrop-gen.js', ]; for (const file of PANELS) { diff --git a/tests/milkdrop-gen-panel.test.js b/tests/milkdrop-gen-panel.test.js new file mode 100644 index 0000000..a759fc8 --- /dev/null +++ b/tests/milkdrop-gen-panel.test.js @@ -0,0 +1,254 @@ +'use strict'; +/* MilkDrop preset üreticisinin arayüzü (#579). + * + * Üretilen preset KAYDEDİLMEDEN yükleniyor. Kaydedilmemiş bir kimliğin + * dokunduğu her yol burada: puan, favori ve etiket yazılmıyor (kayıt + * kütüphanede olmayan bir kimliğe yapışıp kalırdı), ◀ geçmişteki kaydı + * atlıyor, "Kaydet" ekrandakini değil üreticinin son sonucunu yazıyor ve + * kaydedilince aynı kimlik listeye girip düğmeler açılıyor. */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +require('../src/shared/defaults.js'); +require('../src/shared/milkdrop-cycle.js'); +require('../src/shared/milkdrop-library.js'); +const G = require('../src/shared/milkdrop-generator.js'); + +const SV = global.window.SV; +const milk = (r) => '[preset00]\nfRating=' + r + '\nfDecay=0.98\n'; +const LIB = [ + { id: 'm1', name: 'Bir', kind: 'milkdrop', source: milk(5) }, + { id: 'm2', name: 'İki', kind: 'milkdrop', source: milk(4) }, +]; + +// Sahte panel ortamı: el() öğeleri düz nesne olarak kuruyor, çocuklarıyla +async function fresh() { + for (const f of ['../src/admin/milkdrop-panel.js', '../src/admin/milkdrop-gen.js']) { + delete require.cache[require.resolve(f)]; + } + const cfg = SV.defaultConfig(); + const el = (tag, props, kids) => ({ tag, props: props || {}, kids: kids || [] }); + const toasts = []; + const saved = []; + let applies = 0; + window.SVMdGen = G; + window.SVPresets = undefined; + window.SVMdFollow = null; + window.SVI18n = undefined; + // Depo gibi: kaydedilen, sonraki listede + window.api = { + listPresets: () => Promise.resolve(LIB.concat(saved.map((p) => Object.assign({}, p)))), + savePreset: (p) => { + saved.push(p); + return Promise.resolve({ ok: true, preset: Object.assign({}, p, { updatedAt: 1 }) }); + }, + }; + window.SVPanel = { + cfg: () => cfg, + apply() { applies++; }, + rerender() {}, + el, + row: (label, node) => el('row', { label }, [node]), + toast: (m, k) => toasts.push([m, k]), + }; + const M = require('../src/admin/milkdrop-panel.js'); + M.init(); + await new Promise((r) => setImmediate(r)); + const GP = require('../src/admin/milkdrop-gen.js'); + return { M, GP, cfg, toasts, saved, applies: () => applies }; +} + +// Ağaçta koşulu sağlayan ilk düğüm +function find(node, pred) { + if (!node || typeof node !== 'object') return null; + if (pred(node)) return node; + for (const k of node.kids || []) { + const hit = find(k, pred); + if (hit) return hit; + } + return null; +} +function findAll(node, pred, out) { + const acc = out || []; + if (!node || typeof node !== 'object') return acc; + if (pred(node)) acc.push(node); + for (const k of node.kids || []) findAll(k, pred, acc); + return acc; +} + +function wrapStub() { + const w = { kids: [], cls: {}, appendChild(k) { this.kids.push(k); }, classList: { toggle: (c, on) => { w.cls[c] = on; } } }; + Object.defineProperty(w, 'textContent', { set() { w.kids = []; }, get() { return ''; } }); + return w; +} + +test('üretilen preset kaydedilmeden yükleniyor', async () => { + const { GP, cfg, saved, applies } = await fresh(); + GP.regenerate(); + const last = GP.state().last; + assert.ok(last && /^md_gen1_/.test(last.id)); + assert.strictEqual(cfg.milkdrop.presetId, last.id); + assert.strictEqual(cfg.milkdrop.source, last.source); + assert.strictEqual(cfg.milkdrop.name, last.name); + assert.strictEqual(cfg.visualizer.type, 'milkdrop', 'sahne MilkDrop motoruna geçmeli'); + assert.ok(applies() > 0, 'yapılandırma gönderilmeli'); + assert.deepStrictEqual(saved, [], 'önizleme kütüphaneye yazılmamalı'); +}); + +test('kaydedilmemiş önizlemede puan, favori ve etiket yazılmıyor', async () => { + const { M, GP, cfg } = await fresh(); + GP.regenerate(); + const id = GP.state().last.id; + const w = wrapStub(); + M.fillStars(w, cfg); + assert.strictEqual(w.kids.length, 1); + assert.strictEqual(w.kids[0].props.text, '—', 'yıldızlar yerine boş işaret'); + assert.strictEqual(M.act('Favorite'), false); + assert.strictEqual(M.act('RateUp'), false); + assert.strictEqual(M.act('RateDown'), false); + const lib = cfg.milkdropLibrary || {}; + for (const k of ['ratings', 'favorites', 'tags']) { + assert.ok(!lib[k] || !Object.prototype.hasOwnProperty.call(lib[k], id), k + ' kaydedilmemiş kimliğe yazıldı'); + } +}); + +test('önizleme yalnız kaynağı olan bir preseti yüklüyor', async () => { + const { M, cfg } = await fresh(); + const before = JSON.stringify(cfg.milkdrop); + assert.strictEqual(M.preview(null), false); + assert.strictEqual(M.preview({ id: 'md_gen1_x' }), false); + assert.strictEqual(M.preview({ id: '', source: '[preset00]\n' }), false); + assert.strictEqual(JSON.stringify(cfg.milkdrop), before, 'geçersiz önizleme ayara dokunmamalı'); +}); + +test('◀ geçmişteki kaydedilmemiş önizlemeyi atlıyor', async () => { + const { M, GP, cfg } = await fresh(); + M.preview(LIB[0]); + GP.regenerate(); + M.preview(LIB[1]); + assert.strictEqual(cfg.milkdrop.presetId, 'm2'); + M.act('Prev'); + assert.strictEqual(cfg.milkdrop.presetId, 'm1', 'önizlemede durmamalı, ondan önceki presete dönmeli'); +}); + +test('önizlemeden "sonraki" listenin başından sürüyor', async () => { + const { M, GP, cfg } = await fresh(); + GP.regenerate(); + M.act('Next'); + assert.strictEqual(cfg.milkdrop.presetId, 'm1'); +}); + +test('kaydedince aynı kimlik listeye giriyor ve favori açılıyor', async () => { + const { M, GP, cfg, saved, toasts } = await fresh(); + GP.regenerate(); + const last = GP.state().last; + await GP.save(); + assert.strictEqual(saved.length, 1); + assert.deepStrictEqual(saved[0], { + id: last.id, kind: 'milkdrop', name: last.name, source: last.source, author: 'Üretici', + }); + await new Promise((r) => setImmediate(r)); + assert.ok(toasts.some(([m, k]) => k === 'ok' && /kaydedildi/.test(m)), JSON.stringify(toasts)); + // Liste yenilendi: aynı kimlik artık tanınıyor + assert.strictEqual(M.act('Favorite'), true); + assert.strictEqual(cfg.milkdropLibrary.favorites[last.id], true); + const w = wrapStub(); + M.fillStars(w, cfg); + assert.strictEqual(w.kids.length, 6, 'kaydedilince yıldızlar açılmalı'); +}); + +test('"Kaydet" ekrandakini değil üreticinin son sonucunu yazıyor', async () => { + const { GP, cfg, saved } = await fresh(); + GP.regenerate(); + const last = GP.state().last; + // Otomatik geçiş o arada başka bir presete geçti + cfg.milkdrop.presetId = 'm2'; + cfg.milkdrop.source = LIB[1].source; + cfg.milkdrop.name = LIB[1].name; + await GP.save(); + assert.strictEqual(saved.length, 1); + assert.strictEqual(saved[0].id, last.id); + assert.strictEqual(saved[0].source, last.source); +}); + +test('aynı preseti iki kez kaydetmek aynı kimliği yazıyor', async () => { + const { GP, saved } = await fresh(); + GP.regenerate(); + await GP.save(); + await GP.save(); + assert.strictEqual(saved.length, 2); + assert.strictEqual(saved[0].id, saved[1].id, 'depo aynı dosyanın üstüne yazmalı'); +}); + +test('kod alanı: geçersiz kod yüklemiyor, geçerli kod aynı preseti getiriyor', async () => { + const { GP, cfg, toasts } = await fresh(); + const before = cfg.milkdrop.presetId; + GP.fromCode('101-0-0-0-a'); + assert.strictEqual(cfg.milkdrop.presetId, before); + assert.ok(toasts.some(([, k]) => k === 'err')); + GP.fromCode(' 72-15-060-88-2N9C '); + const d = G.decode('72-15-60-88-2n9c'); + const want = G.generate({ axes: d.axes, seed: d.seed }); + assert.strictEqual(cfg.milkdrop.presetId, 'md_gen1_72-15-60-88-2n9c'); + assert.strictEqual(cfg.milkdrop.source, want.source); + assert.deepStrictEqual(GP.state().axes, d.axes); +}); + +test('kaydırıcı sürüklerken yüklemiyor, bırakınca aynı tohumla yüklüyor', async () => { + const { GP, cfg } = await fresh(); + GP.fromCode('50-50-50-50-k3x9ab'); + const tree = GP.panel(); + const ranges = findAll(tree, (n) => n.tag === 'input' && n.props.type === 'range'); + assert.strictEqual(ranges.length, 4); + const energy = ranges[0]; + const id0 = cfg.milkdrop.presetId; + energy.props.oninput({ target: { value: '80' } }); + assert.strictEqual(cfg.milkdrop.presetId, id0, 'sürüklerken yeniden yüklenmemeli'); + energy.props.onchange({ target: { value: '80' } }); + assert.strictEqual(cfg.milkdrop.presetId, 'md_gen1_80-50-50-50-k3x9ab', 'tohum aynı kalmalı'); +}); + +test('🎲 eksenleri bırakıp tohumu değiştiriyor', async () => { + const { GP, cfg } = await fresh(); + GP.fromCode('30-60-40-70-abc'); + const btn = find(GP.panel(), (n) => n.tag === 'button' && /🎲/.test(String(n.props.text))); + btn.props.onclick(); + const d = G.decode(cfg.milkdrop.presetId.replace(/^md_gen1_/, '')); + assert.ok(d, cfg.milkdrop.presetId); + assert.deepStrictEqual(d.axes, { energy: 30, warmth: 60, density: 40, motion: 70 }); + assert.notStrictEqual(d.seed, parseInt('abc', 36), 'tohum değişmeli'); + assert.strictEqual(GP.state().seed, d.seed); +}); + +test('ilk üretimden önce "Kaydet" kapalı; sonra açık ve durum yazıyor', async () => { + const { GP } = await fresh(); + const btn = (tree) => find(tree, (n) => n.tag === 'button' && /Kaydet/.test(String(n.props.text))); + assert.strictEqual(btn(GP.panel()).props.disabled, true); + GP.regenerate(); + const tree = GP.panel(); + assert.strictEqual(btn(tree).props.disabled, false); + assert.ok(find(tree, (n) => n.props && n.props.text === 'Önizleme — kaydedilmedi')); + assert.ok(find(tree, (n) => n.props && n.props.text === GP.state().last.name)); +}); + +test('kart bağlı: bölüm, denetim, betik sırası ve çeviriler', () => { + const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf-8'); + const admin = read('src/admin/admin.js'); + assert.match(admin, /case 'mdgenpanel':\s*\n\s*return window\.SVMdGenPanel \? window\.SVMdGenPanel\.panel\(\) : null;/); + const i = admin.indexOf("id: 'mdgen',"); + assert.ok(i > 0, 'bölüm yok'); + const block = admin.slice(i, i + 600); + assert.match(block, /category: 'studio'/); + assert.match(block, /controls: \[\{ type: 'mdgenpanel' \}\]/); + const title = /title: '([^']+)'/.exec(block)[1]; + const desc = /desc: '([^']+)'/.exec(block)[1]; + const i18n = read('src/shared/i18n.js'); + for (const s of [title, desc]) assert.ok(i18n.indexOf("'" + s + "':") >= 0, 'sözlükte yok: ' + s); + const html = read('src/admin/index.html'); + const at = (s) => html.indexOf(s); + assert.ok(at('../shared/milkdrop-generator.js') > 0, 'üretici yüklenmiyor'); + assert.ok(at('milkdrop-panel.js"') < at('milkdrop-gen.js"'), 'panel üreticiden önce yüklenmeli'); + assert.ok(at('../shared/milkdrop-generator.js') < at('milkdrop-gen.js"')); +}); diff --git a/tests/milkdrop-generator.test.js b/tests/milkdrop-generator.test.js new file mode 100644 index 0000000..4ba49e7 --- /dev/null +++ b/tests/milkdrop-generator.test.js @@ -0,0 +1,574 @@ +'use strict'; +/* MilkDrop preset üreticisi (#579). + + Üretici bir .milk metni yazıyor; buradaki denetimler o metnin + 1) her bloğunun motorun derleyicisinden hatasız geçtiğini, + 2) shader'larının çevirmenden sert ya da yaklaşık not almadan geçtiğini, + 3) renklerinin her ses düzeyinde [0,1] içinde kaldığını (MilkDrop taşan + rengi SARIYOR; taşma kareden kareye siyaha atlama, yani flaş demek), + 4) eksenlerin söz verdiği yönde etki ettiğini, + 5) aynı kodun her platformda aynı metni verdiğini + ölçüyor. Render ölçümü (siyah, beyaza doyma, flaş) Electron istiyor ve + ROADMAP'te; burada motorun Node'da koşan yarısı var. */ +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +const G = require('../src/shared/milkdrop-generator.js'); +const M = require('../src/shared/milkdrop.js'); +const S = require('../src/shared/milkdrop-shader.js'); + +const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16); +const gen = (e, w, d, m, seed, lang) => G.generate({ energy: e, warmth: w, density: d, motion: m, seed, lang }); + +// Testlerin kendi tohum dizisi: sabit, platformdan bağımsız +function seeds(n, start) { + let x = (start || 0x9E3779B9) >>> 0; + const out = []; + for (let i = 0; i < n; i++) { + x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; + out.push(x % G.SEED_LIMIT); + } + return out; +} + +// Bir presetin bütün denklem blokları: [ad, metin] +function blocksOf(f) { + const out = [['init', f.init], ['per_frame', f.perFrame], ['per_pixel', f.perPixel]]; + for (const w of f.waves) { + for (const k of ['init', 'per_frame', 'per_point']) out.push(['wave' + w.index + '.' + k, w[k]]); + } + for (const s of f.shapes) { + for (const k of ['init', 'per_frame']) out.push(['shape' + s.index + '.' + k, s[k]]); + } + return out.filter(([, src]) => src && src.trim()); +} + +// Değişkenleri verilen bir havuzda bloğu koşturur; derleme hatası testi düşürür +function run(src, vars) { + return prep(src)(vars); +} + +/* Bloğu BİR KEZ derler, her çağrıda girdileri yazıp koşturur. Renk + sınırı testi aynı bloğu yüzlerce girdiyle koşturuyor; her seferinde + derlemek testi saniyelerce uzatırdı. */ +function prep(src) { + const p = new M.Pool(); + const c = M.compile(src, p); + assert.strictEqual(c.error, '', 'derleme hatası: ' + c.error + '\n' + src); + return (vars) => { + for (const k of Object.keys(vars || {})) p.set(k, vars[k]); + c.run(); + return p; + }; +} + +// ------------------------------------------------------------------ kod + +test('kod: eksenler ve tohum geri okunuyor, kimlik koddan', () => { + for (const s of seeds(40)) { + const ax = { energy: s % 101, warmth: (s >>> 7) % 101, density: (s >>> 13) % 101, motion: (s >>> 19) % 101 }; + const code = G.encode(ax, s); + const d = G.decode(code); + assert.deepStrictEqual(d, { axes: ax, seed: s }); + assert.strictEqual(G.encode(d.axes, d.seed), code); + const r = G.generate({ axes: ax, seed: s }); + assert.strictEqual(r.code, code); + assert.strictEqual(r.id, G.idOf(code)); + assert.strictEqual(r.id, 'md_gen' + G.VERSION + '_' + code); + } +}); + +test('kod: tek bir yazım — baştaki sıfır, büyük harf ve boşluk aynı kimliğe gidiyor', () => { + const canon = '50-7-0-100-k3x9ab'; + for (const v of ['050-007-000-100-k3x9ab', ' 50 - 7 - 0 - 100 - K3X9AB ', '50-07-0-100-K3x9aB']) { + const d = G.decode(v); + assert.ok(d, v + ' okunamadı'); + assert.strictEqual(G.encode(d.axes, d.seed), canon, v); + } + assert.strictEqual(G.encode(G.decode('1-2-3-4-0003').axes, G.decode('1-2-3-4-0003').seed), '1-2-3-4-3'); +}); + +test('kod: aralık dışı ya da bozuk kod reddediliyor, kırpılmıyor', () => { + /* Sessizce 100'e kırpmak kullanıcıyı istemediği bir presete götürürdü; + geçersiz kod "okunamadı" demeli. */ + for (const v of ['101-0-0-0-a', '0-0-0-0', '0-0-0-0-0-0', '0-0-0-0-abcdefg', '0-0-0-0-ab_c', 'a-0-0-0-1', '', null, undefined, '1000-0-0-0-1']) { + assert.strictEqual(G.decode(v), null, JSON.stringify(v)); + } +}); + +test('kimlik: depo adıyla aynı ve ikinci kayıt kopya bırakmıyor', () => { + /* Depo dosya adını kimlikten türetiyor (presets-store.js `safeName`): + yalnız harf, rakam, alt çizgi ve tire, en çok 80 karakter. Kimlik bu + kümenin dışına taşsaydı dosya adı kimlikten ayrılırdı. */ + const r = G.generate({ energy: 100, warmth: 100, density: 100, motion: 100, seed: G.SEED_LIMIT - 1 }); + assert.match(r.id, /^[A-Za-z0-9_-]{1,80}$/); + + const origLoad = Module._load; + Module._load = function (request) { + if (request === 'electron') return { app: { getPath: () => os.tmpdir() } }; + return origLoad.apply(this, arguments); + }; + let STORE; + try { STORE = require('../src/main/presets-store.js'); } finally { Module._load = origLoad; } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-gen-')); + try { + STORE.setDir(dir); + const p = gen(40, 60, 20, 80, 12345); + const item = { id: p.id, kind: 'milkdrop', name: p.name, source: p.source }; + assert.ok(STORE.save(item).ok); + assert.ok(STORE.save(Object.assign({}, item)).ok); + assert.deepStrictEqual(fs.readdirSync(dir), [p.id + '.json']); + assert.strictEqual(STORE.list().filter((x) => x.id === p.id).length, 1); + } finally { + STORE.setDir(null); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ------------------------------------------------------ sınır yardımcıları + +/* Bugünkü aralıklarda bu sınırlar hiç zorlanmıyor (renkler ve kazançlar + tavanın altında kalıyor), yani üretilen presetler onları sınamıyor. + Aralık genişletildiği gün devreye girecekler; o gün de tutmalılar. */ +const T = G._test; +const numbersIn = (expr) => (expr.match(/-?\d+\.\d+/g) || []).map(Number); + +test('sayı: toFixed, eksi sıfır yok', () => { + assert.strictEqual(T.num(-0.0001), '0.000'); + assert.strictEqual(T.num(-0.0004, 3), '0.000'); + assert.strictEqual(T.num(-0.0001, 4), '-0.0001'); + assert.strictEqual(T.num(-0.25), '-0.250'); + assert.strictEqual(T.num(1 / 3), '0.333'); + assert.strictEqual(T.num(NaN), '0.000'); + assert.strictEqual(T.num(Infinity), '0.000'); +}); + +test('iki değer arası salınım yuvarlamadan sonra da [0,1] içinde', () => { + /* Kanal 1,0 olabiliyor (doygunluk 1, açıklık 0,5'in üstünde). Orta ve + genlik ayrı yuvarlanınca toplamları 1,001'e çıkabiliyor: MilkDrop onu + 0'a sarar, renk vuruşla yanıp söner. */ + const vals = [0, 0.0004, 0.0005, 0.0006, 0.0015, 0.4995, 0.5, 0.5005, 0.9985, 0.9995, 0.9996, 1]; + let x = 12345; + for (let i = 0; i < 400; i++) { x = (x * 1103515245 + 12345) % 2147483648; vals.push(Math.round((x / 2147483648) * 10000) / 10000); } + for (const a of vals) { + for (const b of [0, 1, 0.5005, 0.4995, 0.9995, 0.0005, vals[(Math.round(a * 997)) % vals.length]]) { + const e = T.sway(a, b, 'time'); + const n = numbersIn(e); + const mid = n[0]; + const amp = n.length > 1 ? n[1] : 0; + assert.ok(mid - amp >= -1e-12 && mid + amp <= 1 + 1e-12, a + ' → ' + b + ': ' + e); + } + } +}); + +test('sesle büyüyen değer tavanı aşmıyor, sesin en yüksek hâlinde de', () => { + for (const cap of [0.2, 0.4, 0.6, 0.9, 0.95]) { + for (const base of [0, 0.05, 0.1995, 0.3, cap, cap + 0.2]) { + for (const gain of [0, 0.001, 0.05, 0.3, 2]) { + const e = T.withAudio(base, gain, 'q1', cap); + const n = numbersIn(e); + const top = n[0] + (n.length > 1 ? n[1] * G.AUDIO_CAP : 0); + assert.ok(top <= cap + 1e-12, e + ' tavan ' + cap); + assert.ok(n[0] >= 0, e); + } + } + } +}); + +// ------------------------------------------------------------ belirlilik + +test('aynı kod aynı metni veriyor; dil yalnız adı değiştiriyor', () => { + for (const s of seeds(12, 7)) { + const a = gen(s % 101, 30, 70, 50, s, 'tr'); + const b = gen(s % 101, 30, 70, 50, s, 'tr'); + const c = gen(s % 101, 30, 70, 50, s, 'en'); + assert.strictEqual(a.source, b.source); + assert.strictEqual(a.source, c.source); + assert.strictEqual(a.name, b.name); + assert.notStrictEqual(a.name, c.name); + assert.ok(a.name.endsWith(' · ' + s.toString(36)), a.name); + assert.ok(c.name.endsWith(' · ' + s.toString(36)), c.name); + assert.ok(!/[çğıöşüÇĞİÖŞÜâ]/.test(c.name), 'İngilizce adda Türkçe harf: ' + c.name); + } +}); + +/* ALTIN ÖZETLER. Bir kalıp ya da sayı değişirse aynı kod başka bir preset + verir; kaydedilmiş bir kodu paylaşan kullanıcı başka bir şey görür. Bu + test düşerse değişiklik bilerek yapıldıysa VERSION artırılmalı (kimlik + de değişir, eski kayıtların üstüne yazılmaz) ve özetler yenilenmeli. + Windows ve Ubuntu'da, Node 20 ve 22'de aynı çıkmalı: üretici yazdığı + sayılara sin/pow/exp sokmuyor. */ +const GOLDEN = { + '0-0-0-0-0': '471e4ed82802f52e', // warp shader'ı, zorlanan tek dalga + '100-100-100-100-zzzzzz': '39d6604bf92852c8', // birleştirme, 4 dalga, 3 şekil + '50-50-50-50-k3x9ab': '63ae6d15b3fcfc68', // iki shader + '72-15-60-88-2n9c': '78b50f1e94a2c875', + '20-80-35-65-1a2b3c': 'c01c340ae467e8ad', + '90-40-90-10-7': 'f2e5665d620a10a7', + '30-60-40-30-5': 'e7ced80c3c4ab9f4', // shader yok (MilkDrop 1 biçimi) +}; + +test('altın özetler: kod -> metin sabit', () => { + for (const code of Object.keys(GOLDEN)) { + const d = G.decode(code); + const r = G.generate({ axes: d.axes, seed: d.seed }); + assert.strictEqual(sha(r.source), GOLDEN[code], code + ' değişti — VERSION artırılmalı mı?'); + } +}); + +// -------------------------------------------------------------- geçerlilik + +/* Eksen ızgarası (her eksende 0, 33, 67, 100) x iki tohum: 512 preset. */ +function grid() { + const vals = [0, 33, 67, 100]; + const out = []; + const ss = seeds(2 * 256, 0xA5A5A5A5); + let i = 0; + for (const e of vals) for (const w of vals) for (const d of vals) for (const m of vals) { + out.push(gen(e, w, d, m, ss[i++])); + out.push(gen(e, w, d, m, ss[i++])); + } + return out; +} +const GRID = grid(); + +test('ızgara: her blok derleniyor, bilinmeyen işlev yok', () => { + const known = new Set(Object.keys(M.FUNCS)); + for (const r of GRID) { + const f = M.parseMilk(r.source); + const blocks = blocksOf(f); + assert.ok(blocks.length >= 2, r.code + ': blok yok'); + for (const [name, src] of blocks) { + const c = M.compile(src); + assert.strictEqual(c.error, '', r.code + ' ' + name + ': ' + c.error); + assert.ok(c.statements > 0, r.code + ' ' + name + ': boş'); + for (const m of src.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) { + assert.ok(known.has(m[1].toLowerCase()), r.code + ' ' + name + ': bilinmeyen işlev ' + m[1]); + } + } + // Panelin derleme satırının kullandığı yol da hatasız + const p = new M.Preset(r.source); + assert.deepStrictEqual(p.errors, [], r.code); + } +}); + +test('ızgara: shader\'lar çevrilebiliyor, yaklaşık doku yok', () => { + let warp = 0; + let comp = 0; + for (const r of GRID) { + const f = M.parseMilk(r.source); + for (const [stage, text] of [['warp', f.warpShader], ['comp', f.compShader]]) { + if (!text) continue; + const t = S.translate(text, { stage }); + assert.deepStrictEqual(t.hard, [], r.code + ' ' + stage); + assert.deepStrictEqual(t.soft, [], r.code + ' ' + stage); + assert.ok(t.glsl.length > 0, r.code + ' ' + stage + ': boş GLSL'); + if (stage === 'warp') warp++; else comp++; + } + // Sürüm satırları shader'larla tutarlı: MilkDrop 2 yalnız bunlara bakıp okuyor + const has = (k, v) => r.source.indexOf('\n' + k + '=' + v + '\n') >= 0 || r.source.indexOf(k + '=' + v + '\n') === 0; + if (f.warpShader || f.compShader) { + assert.ok(has('MILKDROP_PRESET_VERSION', '201'), r.code); + assert.ok(has('PSVERSION_WARP', f.warpShader ? '2' : '0'), r.code); + assert.ok(has('PSVERSION_COMP', f.compShader ? '2' : '0'), r.code); + } else { + assert.ok(r.source.indexOf('PSVERSION') < 0, r.code + ': shader yok ama sürüm satırı var'); + } + } + // Izgarada iki aşama da gerçekten üretiliyor + assert.ok(warp > 50 && comp > 100, 'warp ' + warp + ', comp ' + comp); +}); + +test('ızgara: sayılar yalnız noktalı ondalık; "+ -" ve üstel gösterim yok', () => { + for (const r of GRID) { + for (const line of r.source.split('\n')) { + assert.ok(!/\d,\d/.test(line), r.code + ' virgül: ' + line); + assert.ok(!/\d[eE][+-]?\d/.test(line), r.code + ' üstel: ' + line); + assert.ok(!/NaN|Infinity|undefined/.test(line), r.code + ': ' + line); + assert.ok(!/[+\-*/]\s*-\s*\d/.test(line.replace(/^[^=]*=/, '')), r.code + ' ardışık işaret: ' + line); + assert.ok(!/-0\.0+(?![0-9])/.test(line), r.code + ' eksi sıfır: ' + line); + } + } +}); + +test('ızgara: flaşa götüren hiçbir şey yok', () => { + for (const r of GRID) { + const f = M.parseMilk(r.source); + for (const k of ['binvert', 'bsolarize', 'bbrighten', 'bdarken']) { + assert.strictEqual(f.params[k], 0, r.code + ' ' + k); + } + /* Basamak işlevi yok: vuruşta bir anda açılıp kapanan bir değer + üretemiyor. rand da yok: aynı kod her açılışta aynı görüntü. */ + for (const [name, src] of blocksOf(f)) { + assert.ok(!/\b(above|below|equal|if|rand|bnot|band|bor|sign|floor|int|ceil)\s*\(/i.test(src), r.code + ' ' + name + ': ' + src); + } + } +}); + +// ------------------------------------------------------------ renk sınırı + +const AUDIO = [0, 0.4, 1, 2.5, 20]; +const TIMES = [0, 1.7, 13.3, 101.9, 1234.5]; + +function mainVars(f, t, a) { + const v = { time: t, fps: 30, frame: Math.round(t * 30), progress: 0.5, aspectx: 1, aspecty: 16 / 9 }; + for (const k of ['bass', 'mid', 'treb', 'bass_att', 'mid_att', 'treb_att']) v[k] = a; + for (const k of ['zoom', 'rot', 'warp', 'cx', 'cy', 'dx', 'dy', 'sx', 'sy', 'wave_r', 'wave_g', 'wave_b', 'wave_a', 'decay']) { + if (typeof f.params[k] === 'number') v[k] = f.params[k]; + } + return v; +} + +const inUnit = (v) => v >= 0 && v <= 1; + +test('renkler her ses düzeyinde [0,1] içinde: MilkDrop taşanı sarıyor', () => { + const sample = GRID.filter((_, i) => i % 4 === 0); + for (const r of sample) { + const f = M.parseMilk(r.source); + for (const k of Object.keys(f.params)) { + if (/^(wave_[rgba]|ob_[rgba]|ib_[rgba]|mv_[rgba]|wavecode_\d+_[rgba]|shapecode_\d+_(r|g|b|a|r2|g2|b2|a2|border_[rgba]))$/.test(k)) { + assert.ok(inUnit(f.params[k]), r.code + ' başlık ' + k + '=' + f.params[k]); + } + } + const frameRun = prep(f.perFrame); + const waveRuns = f.waves.map((w) => [w, prep(w.per_frame || ''), prep(w.per_point)]); + const shapeRuns = f.shapes.map((sh) => [sh, prep(sh.per_frame)]); + for (const t of TIMES) { + for (const a of AUDIO) { + const main = frameRun(mainVars(f, t, a)); + for (const k of ['wave_r', 'wave_g', 'wave_b']) { + assert.ok(inUnit(main.get(k)), r.code + ' ' + k + '=' + main.get(k) + ' (t=' + t + ', ses=' + a + ')'); + } + for (const q of ['q1', 'q2', 'q3']) { + assert.ok(main.get(q) >= 0 && main.get(q) <= G.AUDIO_CAP, r.code + ' ' + q + '=' + main.get(q)); + } + const shared = { time: t, fps: 30, frame: Math.round(t * 30) }; + for (const k of ['bass', 'mid', 'treb', 'bass_att', 'mid_att', 'treb_att']) shared[k] = a; + for (let i = 1; i <= 32; i++) shared['q' + i] = main.get('q' + i); + for (const [w, wf, wp] of waveRuns) { + const pool = wf(Object.assign({}, shared)); + const tv = {}; + for (let i = 1; i <= 8; i++) tv['t' + i] = pool.get('t' + i); + for (const s of [0, 0.37, 1]) { + for (const v of [-1, 0, 1]) { + const pt = wp(Object.assign({}, shared, tv, { sample: s, value1: v, value2: -v })); + for (const k of ['r', 'g', 'b', 'a']) { + assert.ok(inUnit(pt.get(k)), r.code + ' dalga ' + w.index + ' ' + k + '=' + pt.get(k)); + } + } + } + } + for (const [sh, sf] of shapeRuns) { + const n = f.params['shapecode_' + sh.index + '_num_inst'] || 1; + for (const inst of [0, n - 1]) { + const vars = Object.assign({}, shared, { instance: inst, num_inst: n }); + for (const k of ['r', 'g', 'b', 'a', 'r2', 'g2', 'b2', 'a2', 'border_r', 'border_g', 'border_b', 'border_a']) { + vars[k] = f.params['shapecode_' + sh.index + '_' + k]; + } + const out = sf(vars); + for (const k of ['r', 'g', 'b', 'a', 'r2', 'g2', 'b2', 'a2', 'border_r', 'border_g', 'border_b', 'border_a']) { + assert.ok(inUnit(out.get(k)), r.code + ' şekil ' + sh.index + ' ' + k + '=' + out.get(k)); + } + } + } + } + } + } +}); + +/* Render ölçümünün bulduğu üç kusurun kuralları (ROADMAP #579). Ölçüm + Electron istiyor; kurallar burada, bir sonraki değişiklik onları + sessizce geri almasın diye. */ +test('seyrek preset boş değil: en az bir özel dalga ya da şekil', () => { + // Yalnız ana dalgayla kalan presetler siyaha yakın çıkıyordu + for (const r of GRID) { + assert.ok(r.parts.waves.length + r.parts.shapes.length >= 1, r.code); + } +}); + +test('sessizlikte nokta olan ana dalga kipleri yalnız yoğun presetlerde', () => { + /* 1, 2, 3 ve 5 sol ve sağ kanalı birbirine karşı çiziyor: sessizlikte + tek bir nokta. Yoğunluk 0'da ve ikiden az öğeli presette hiç. */ + for (const sd of seeds(200, 0x51)) { + const f = M.parseMilk(gen(sd % 101, (sd >>> 8) % 101, 0, (sd >>> 16) % 101, sd).source); + assert.ok([0, 4, 6, 7].indexOf(f.params.nwavemode) >= 0, 'yoğunluk 0, kip ' + f.params.nwavemode); + } + let dense = 0; + for (const r of GRID) { + const n = r.parts.waves.length + r.parts.shapes.length; + const mode = M.parseMilk(r.source).params.nwavemode; + if (n < 2) assert.ok([0, 4, 6, 7].indexOf(mode) >= 0, r.code + ': ' + n + ' öğe, kip ' + mode); + else if ([1, 2, 3, 5].indexOf(mode) >= 0) dense++; + } + assert.ok(dense > 20, 'yoğun presetlerde bu kipler hiç çıkmıyor: ' + dense); +}); + +test('ortayı karartma ve noktalı ana dalga yalnız başka öğeler varken', () => { + for (const r of GRID) { + const f = M.parseMilk(r.source); + const elements = r.parts.waves.length + r.parts.shapes.length; + if (f.params.bdarkencenter === 1) assert.ok(r.axes.density >= 40, r.code + ': seyrek presette orta karartılmış'); + if (f.params.bwavedots === 1) assert.ok(elements >= 2, r.code + ': tek başına noktalı dalga'); + } +}); + +test('sessizlikte de değişiyor: halka dalganın yarıçapı ve çekirdeğin boyu zamanla oynuyor', () => { + /* Düz bir halkayı ya da yuvarlak bir çekirdeği döndürmek görüntüyü + değiştirmiyor; sessizlikte bu öğelerle kalan preset donmuş görünüyordu. + Ses yokken (q = 0, dalga düz) iki ayrı anda bakılıyor. */ + let rings = 0; + let cores = 0; + for (const r of GRID) { + const f = M.parseMilk(r.source); + const silent = (t) => run(f.perFrame, mainVars(f, t, 0)); + f.waves.forEach((w, i) => { + if (r.parts.waves[i] !== 'halka') return; + rings++; + const at = (t) => { + const main = silent(t); + const shared = { time: t, fps: 30, frame: Math.round(t * 30) }; + for (let q = 1; q <= 10; q++) shared['q' + q] = main.get('q' + q); + const pool = run(w.per_frame, shared); + const tv = {}; + for (let k = 1; k <= 8; k++) tv['t' + k] = pool.get('t' + k); + return run(w.per_point, Object.assign({}, shared, tv, { sample: 0.3, value1: 0, value2: 0 })).get('t4'); + }; + assert.notStrictEqual(at(1), at(2.5), r.code + ' halka ' + w.index + ' sessizlikte sabit'); + }); + f.shapes.forEach((sh, i) => { + if (r.parts.shapes[i] !== 'cekirdek') return; + cores++; + const at = (t) => { + const main = silent(t); + const v = { time: t, instance: 0, num_inst: 1 }; + for (let q = 1; q <= 10; q++) v['q' + q] = main.get('q' + q); + return run(sh.per_frame, v).get('rad'); + }; + assert.notStrictEqual(at(1), at(2.5), r.code + ' çekirdek sessizlikte sabit'); + }); + } + assert.ok(rings > 20 && cores > 20, 'halka ' + rings + ', çekirdek ' + cores); +}); + +test('sönme ve gama makul aralıkta; warp shader\'ı sönmeyi kendisi yapıyor', () => { + for (const r of GRID) { + const f = M.parseMilk(r.source); + /* Üst sınır 0,98: 0,99'da aynı yere her kare çizilen bir çizgi + sessizlikte bile birikip ekranın %61'ini beyaza boğdu. */ + assert.ok(f.params.fdecay >= 0.9 && f.params.fdecay <= 0.98, r.code + ' fDecay ' + f.params.fdecay); + assert.ok(f.params.fgammaadj >= 1 && f.params.fgammaadj <= 1.8, r.code + ' gama ' + f.params.fgammaadj); + // Ana dalga sönme yavaşladıkça saydamlaşıyor: 0,97'de tam, 0,98'de üçte iki + const room = Math.max(0.5, Math.min(1, (1 - f.params.fdecay) / 0.03)); + assert.ok(f.params.wave_a <= 0.9 * room + 0.001, r.code + ' wave_a ' + f.params.wave_a + ' sönme ' + f.params.fdecay); + if (f.warpShader) { + /* MilkDrop warp shader'ı olan presette decay'i uygulamıyor. q8'i + yazmayan bir warp shader'ı görüntüyü hiç söndürmez ve beyaza doyar. */ + assert.match(f.warpShader, /\*q8\b/, r.code); + const main = run(f.perFrame, mainVars(f, 3, 1)); + const q8 = main.get('q8'); + assert.ok(Math.abs(q8 - f.params.fdecay) < 1e-9, r.code + ' q8 ' + q8); + // 60 fps'te kare başına karekök: saniyelik sönme aynı + const main60 = run(f.perFrame, Object.assign(mainVars(f, 3, 1), { fps: 60 })); + assert.ok(Math.abs(main60.get('q8') * main60.get('q8') - f.params.fdecay) < 1e-9, r.code); + } + } +}); + +// -------------------------------------------------------------- eksenler + +/* Tek bir tohumda bir eksen kalıp seçimini değiştirebilir; etkiler 60 + tohumun ORTALAMASINDA sınanıyor. */ +const AX_SEEDS = seeds(60, 0x1234567); +const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length; + +// Ses duyarlılığı: aynı anda sessiz (0,5) ile yüksek (2,0) ses arasındaki fark +function audioSensitivity(src) { + const f = M.parseMilk(src); + let s = 0; + const lo = run(f.perFrame, mainVars(f, 7.3, 0.5)); + const hi = run(f.perFrame, mainVars(f, 7.3, 2.0)); + for (const k of ['zoom', 'rot', 'warp', 'cx', 'cy', 'dx', 'dy', 'sx', 'sy']) s += Math.abs(hi.get(k) - lo.get(k)); + for (const sh of f.shapes) { + const at = (a, main) => { + const v = { time: 7.3, instance: 0, num_inst: 1 }; + for (const k of ['bass_att', 'mid_att', 'treb_att']) v[k] = a; + for (let i = 1; i <= 10; i++) v['q' + i] = main.get('q' + i); + return run(sh.per_frame, v); + }; + const a = at(0.5, lo); + const b = at(2.0, hi); + for (const k of ['rad', 'a', 'border_a']) s += Math.abs(b.get(k) - a.get(k)); + } + return s; +} + +test('enerji: sese tepki ortalamada artıyor', () => { + const at = (e) => mean(AX_SEEDS.map((sd) => audioSensitivity(gen(e, 50, 50, 50, sd).source))); + const s0 = at(0); + const s50 = at(50); + const s100 = at(100); + assert.ok(s0 < s50 && s50 < s100, [s0, s50, s100].join(' < ')); + assert.ok(s100 > 2 * s0, 'enerji 100, 0\'ın iki katından az tepki veriyor: ' + s0 + ' / ' + s100); +}); + +test('yoğunluk: öğe sayısı ortalamada artıyor', () => { + const count = (src) => (src.match(/^(wavecode|shapecode)_\d+_enabled=1$/gm) || []).length + + (src.match(/^per_pixel_\d+=/gm) || []).length; + const at = (d) => mean(AX_SEEDS.map((sd) => count(gen(50, 50, d, 50, sd).source))); + const c0 = at(0); + const c50 = at(50); + const c100 = at(100); + assert.ok(c0 < c50 && c50 < c100, [c0, c50, c100].join(' < ')); + assert.ok(c100 - c0 > 3, 'yoğunluk farkı küçük: ' + c0 + ' -> ' + c100); +}); + +test('hareket: zaman katsayıları ortalamada artıyor', () => { + const speed = (src) => mean(Array.from(src.matchAll(/time\*(\d+\.\d+)/g)).map((m) => Number(m[1]))); + const at = (m) => mean(AX_SEEDS.map((sd) => speed(gen(50, 50, 50, m, sd).source))); + const v0 = at(0); + const v50 = at(50); + const v100 = at(100); + assert.ok(v0 < v50 && v50 < v100, [v0, v50, v100].join(' < ')); + assert.ok(v100 > 3 * v0, 'hareket 100, 0\'ın üç katından yavaş: ' + v0 + ' / ' + v100); +}); + +test('sıcaklık: renkler soğuktan sıcağa kayıyor', () => { + // Başlıktaki bütün renklerin kırmızı eksi mavi ortalaması + const warmth = (src) => { + const f = M.parseMilk(src); + const xs = []; + for (const k of Object.keys(f.params)) { + const m = /^(wave|wavecode_\d+|shapecode_\d+)_r$/.exec(k); + if (!m) continue; + const b = f.params[m[1] + '_b']; + if (typeof b === 'number') xs.push(f.params[k] - b); + } + return mean(xs); + }; + const at = (w) => mean(AX_SEEDS.map((sd) => warmth(gen(50, w, 50, 50, sd).source))); + const w0 = at(0); + const w50 = at(50); + const w100 = at(100); + assert.ok(w0 < w50 && w50 < w100, [w0, w50, w100].join(' < ')); + assert.ok(w0 < -0.2 && w100 > 0.2, 'soğuk ' + w0 + ', sıcak ' + w100); +}); + +test('eksen oynatmak presetin geri kalanını yerinde bırakıyor', () => { + /* Her bileşen kendi akışından çekiyor: yoğunluk ya da hareket değişince + palet aynı; sıcaklık değişince kalıplar aynı. Kaydırıcı başka bir + preset değil, aynı presetin başka bir hâlini vermeli. */ + const colors = (src) => src.split('\n').filter((l) => /^(wave_[rgb]|ob_[rgb]|ib_[rgb]|mv_[rgb])=/.test(l)).join('|'); + for (const sd of AX_SEEDS.slice(0, 30)) { + const base = gen(60, 40, 50, 50, sd); + assert.strictEqual(colors(gen(60, 40, 5, 50, sd).source), colors(base.source), 'yoğunluk paleti değiştirdi'); + assert.strictEqual(colors(gen(60, 40, 95, 95, sd).source), colors(base.source), 'hareket paleti değiştirdi'); + const warm = gen(60, 95, 50, 50, sd); + assert.deepStrictEqual(warm.parts, base.parts, 'sıcaklık kalıpları değiştirdi'); + assert.notStrictEqual(colors(warm.source), colors(base.source)); + } +});