From 014be130b42ba422d02bdaf01c19f528e3715b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 23 Sep 2026 06:28:33 +0300 Subject: [PATCH 1/4] feat(milkdrop): mash-ups of library presets A preset built out of six parts of others: look, motion, waves, shapes, warp shader, and composite shader with the blur ranges. - Every key belongs to exactly one part (86 header keys counted from the corpus). Parts come whole and their lines are copied as they are; version lines are rewritten from the presets giving the shaders. - Part tests pick candidates without parsing and agree with the parser on all 10,332 corpus presets. - A one-preset mash-up is that preset again for the whole corpus; in 5,000 random mash-ups every part equals its donor's. 300 random mash-ups ran in the engine with no new shader or equation failure. Written from a description of the behaviour in our own words; MilkDrop 2's mash-up code was not opened. Refs #579 --- src/shared/milkdrop-mashup.js | 254 ++++++++++++++++++++++++++++++++++ tests/milkdrop-mashup.test.js | 247 +++++++++++++++++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 src/shared/milkdrop-mashup.js create mode 100644 tests/milkdrop-mashup.test.js diff --git a/src/shared/milkdrop-mashup.js b/src/shared/milkdrop-mashup.js new file mode 100644 index 0000000..f5122ae --- /dev/null +++ b/src/shared/milkdrop-mashup.js @@ -0,0 +1,254 @@ +'use strict'; +/* MİLKDROP KARIŞIMI (#579) — kütüphanedeki presetlerin parçalarından yeni bir preset. + + NE YAPIYOR (kendi sözlerimizle; MilkDrop 2'nin kodu okunmadı): MilkDrop 2 + bir preseti başka presetlerin parçalarından kurabiliyor — parçalar ya + rastgele seçiliyor ya da her parçanın hangi presetten geleceği tek tek + seçiliyor. Buradaki karışım aynı fikri kendi yolundan kuruyor: altı + parça, her biri BÜTÜNÜYLE bir presetten. + + look — görünüm: sönme, gama, yankı, bayraklar, ana dalga, kenarlar, + hareket vektörleri, puan + motion — hareket: per_frame_init, per_frame, per_pixel denklemleri ve + zoom, rot, cx, cy, dx, dy, warp, sx, sy, fWarpAnimSpeed, + fWarpScale, fZoomExponent + waves — özel dalgalar (wavecode_*, wave_N_*) + shapes — özel şekiller (shapecode_*, shape_N_*) + warp — warp shader'ı (warp_N) + comp — birleştirme shader'ı (comp_N) ve bulanıklık aralıkları + (b1n..b3x, b1ed): GetBlur'un ölçeği onlardan geliyor ve + bulanıklığı en çok birleştirme shader'ı okuyor + + SATIRLAR OLDUĞU GİBİ KOPYALANIYOR, numaraları değiştirilmeden. Her anahtar + tam olarak bir parçaya ait; bir parçanın bütün satırları aynı presetten + geldiği için numaralar kendi içinde zaten sürekli. Anahtarların listesi + korpustan sayıldı (10.332 dosya, 86 başlık anahtarı); bilinmeyen bir + başlık anahtarı görünüme gidiyor. + + Sürüm satırları kopyalanmıyor, yeniden yazılıyor: MilkDrop 2 shader'ı + ancak PSVERSION_WARP/PSVERSION_COMP sıfırdan büyükse okuyor, yani o + değerler shader'ı veren presetinkiler olmalı. + + Parçalar arası bağ KORUNMUYOR ve korunamaz: bir presetin shader'ı başka + bir presetin kare denklemlerinin yazdığı q değişkenlerini okuyor olabilir. + Karışımın doğası bu; MilkDrop 2'de de öyle. */ +(function () { + const VERSION = 1; + const SLOTS = ['look', 'motion', 'waves', 'shapes', 'warp', 'comp']; + // Shader parçası "yok" olabilir: görünümün sabit yolu (yankı, gama, bayraklar) o zaman çalışıyor + const NONE = ''; + + const MOTION_KEYS = new Set([ + 'zoom', 'rot', 'cx', 'cy', 'dx', 'dy', 'warp', 'sx', 'sy', + 'fwarpanimspeed', 'fwarpscale', 'fzoomexponent', + ]); + const BLUR_KEYS = new Set(['b1n', 'b2n', 'b3n', 'b1x', 'b2x', 'b3x', 'b1ed']); + const VERSION_KEYS = new Set(['milkdrop_preset_version', 'psversion', 'psversion_warp', 'psversion_comp']); + + /* Anahtarın parçası. Sıra önemli: per_frame_init_N, per_frame_N'den önce + sınanmalı; `wave_N_` başlıktaki `wave_r`dan rakamla ayrılıyor; `warp_N` + shader, düz `warp` hareket. */ + function slotOf(key) { + const k = String(key).trim().toLowerCase(); + if (VERSION_KEYS.has(k)) return 'version'; + if (/^per_frame_init_\d+$/.test(k) || /^per_frame_\d+$/.test(k) || + /^per_pixel_\d+$/.test(k) || /^per_vertex_\d+$/.test(k) || MOTION_KEYS.has(k)) return 'motion'; + if (/^wavecode_\d+_/.test(k) || /^wave_\d+_(init|per_frame|per_point)\d+$/.test(k)) return 'waves'; + if (/^shapecode_\d+_/.test(k) || /^shape_\d+_(init|per_frame)\d+$/.test(k)) return 'shapes'; + if (/^warp_\d+$/.test(k)) return 'warp'; + if (/^comp_\d+$/.test(k) || BLUR_KEYS.has(k)) return 'comp'; + return 'look'; + } + + /* Metni parçalarına ayırır. Eşittirsiz satırlar ve bölüm başlıkları + düşüyor (ayrıştırıcı da onları okumuyor). Dönüş: parça -> satırlar ve + sürüm satırlarının değerleri. */ + function split(text) { + const out = { look: [], motion: [], waves: [], shapes: [], warp: [], comp: [] }; + const ver = {}; + for (const raw of String(text == null ? '' : text).split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line[0] === '[') continue; + const eq = line.indexOf('='); + if (eq < 0) continue; + const key = line.slice(0, eq).trim().toLowerCase(); + const slot = slotOf(key); + if (slot === 'version') { + const v = parseFloat(line.slice(eq + 1)); + if (isFinite(v)) ver[key] = v; + continue; + } + /* Satırın iki ucu kırpılmış hâli: ayrıştırıcı da satırı öyle okuyor. + Shader girintisi ters tırnaktan SONRA, kırpmadan etkilenmiyor. */ + out[slot].push(line); + } + return { lines: out, ver }; + } + + /* Bir parçası var mı — ayrıştırıcıyla aynı cevap, ama ayrıştırmadan. + Aday seçimi on bin presetlik bir listede satır satır arıyor; her aday + için tam ayrıştırma pahalı olurdu. Ölçüldü: korpusun 10.332 presetinde + altı parçanın hepsi için ayrıştırıcıyla birebir aynı (ROADMAP #579). + + motion — boş olmayan bir denklem satırı (yorum dışında) + waves/shapes — açık (enabled sıfırdan farklı) bir blok + warp/comp — boş olmayan bir shader satırı */ + function has(text, slot) { + if (slot === 'look') return true; + const s = String(text == null ? '' : text); + let re; + if (slot === 'motion') re = /^[ \t]*(per_frame_init_\d+|per_frame_\d+|per_pixel_\d+|per_vertex_\d+)[ \t]*=(.*)$/gmi; + else if (slot === 'waves') re = /^[ \t]*wavecode_\d+_enabled[ \t]*=(.*)$/gmi; + else if (slot === 'shapes') re = /^[ \t]*shapecode_\d+_enabled[ \t]*=(.*)$/gmi; + else if (slot === 'warp') re = /^[ \t]*warp_\d+[ \t]*=(.*)$/gmi; + else if (slot === 'comp') re = /^[ \t]*comp_\d+[ \t]*=(.*)$/gmi; + else return false; + let m; + /* Açık bloklar anahtar başına SON değerle: aynı anahtar dosyada iki kez + geçebiliyor (korpusta `shapecode_2_enabled=1`, sonra `=0`) ve + ayrıştırıcı sonuncuyu tutuyor. */ + const last = new Map(); + while ((m = re.exec(s)) !== null) { + const v = m[m.length - 1]; + if (slot === 'motion') { + // Ayrıştırıcı satır yorumunu atıyor: yalnız yorum olan satır boş sayılır + if (v.replace(/\/\/.*$/, '').trim()) return true; + } else if (slot === 'waves' || slot === 'shapes') { + last.set(m[0].slice(0, m[0].indexOf('=')).trim().toLowerCase(), v); + } else if (v.replace(/^`/, '').trim()) { + return true; + } + } + for (const v of last.values()) { + const n = parseFloat(v); + if (isFinite(n) && n !== 0 && /^[\s\-+.0-9eE]+$/.test(v)) return true; + } + return false; + } + + /* Bir parçanın shader sürümü: parçanın kendi satırı, yoksa genel sürüm, + yoksa 2; shader satırı yoksa 0. Yalnız `warp_N`/`comp_N` satırlarına + bakılıyor: birleştirme parçasında bulanıklık aralıkları da var ve + shader'ı olmayan bir presette onları shader sanmak, 308 MilkDrop 1 + presetine olmayan bir shader için sürüm satırı yazıyordu (ölçüm). */ + function shaderVersion(parts, slot) { + const re = slot === 'warp' ? /^warp_\d+[ \t]*=/i : /^comp_\d+[ \t]*=/i; + if (!parts.lines[slot].some((l) => re.test(l) && l.slice(l.indexOf('=') + 1).replace(/^`/, '').trim())) return 0; + const own = parts.ver['psversion_' + slot]; + if (own > 0) return own; + return parts.ver.psversion > 0 ? parts.ver.psversion : 2; + } + + /* Karışımı yazar. `donors`: parça -> preset metni. warp ve comp için + NONE ('') "shader yok" demek; tanımsız bir parça görünümün presetinden + geliyor. */ + function compose(donors) { + const d = donors || {}; + const look = split(d.look); + /* NONE boş metin: boş metnin hiçbir parçası yok, yani "shader yok" ayrı + bir dal istemiyor. Tanımsız parça görünümün presetinden. */ + const parts = {}; + for (const slot of SLOTS) { + const t = d[slot]; + parts[slot] = slot === 'look' || t === undefined || t === null ? look : split(t); + } + const warpV = shaderVersion(parts.warp, 'warp'); + const compV = shaderVersion(parts.comp, 'comp'); + const lines = []; + /* Sürüm satırları shader'ı verenlerden. İkisi de yoksa ve görünümün + presetinde sürüm satırları varsa onun değerleri korunuyor: tek + presetten kurulan karışım kendisini birebir veriyor. */ + const lookHasVer = look.ver.milkdrop_preset_version > 0; + if (warpV || compV || lookHasVer) { + const ps = Math.max(warpV, compV) || look.ver.psversion || 2; + lines.push('MILKDROP_PRESET_VERSION=' + (look.ver.milkdrop_preset_version > 0 ? look.ver.milkdrop_preset_version : 201)); + lines.push('PSVERSION=' + ps); + lines.push('PSVERSION_WARP=' + warpV); + lines.push('PSVERSION_COMP=' + compV); + } + lines.push('[preset00]'); + for (const slot of SLOTS) { + for (const l of parts[slot].lines[slot]) lines.push(l); + } + return lines.join('\n') + '\n'; + } + + // -------------------------------------------------------------------- + // Tarif, kimlik, ad, aday + // -------------------------------------------------------------------- + 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; + } + + // Tarifin tek metni: parça sırası sabit, "yok" boş dize + const recipeKey = (r) => SLOTS.map((s) => s + ':' + (r && typeof r[s] === 'string' ? r[s] : '')).join('|'); + + /* Kimlik tariften: aynı karışımı iki kez kaydetmek ikinci bir kopya + bırakmıyor. Sürüm kimlikte, çünkü parça kuralları değişirse aynı tarif + başka bir metin verir ve eski kaydın üstüne yazılmamalı. İki ayrı özet + (64 bit) çakışmayı uzak tutuyor. */ + function idOf(recipe) { + const k = recipeKey(recipe); + const a = hashString(k).toString(16).padStart(8, '0'); + const b = hashString('mix:' + k + ':' + k.length).toString(16).padStart(8, '0'); + return 'md_mix' + VERSION + '_' + a + b; + } + + const WORD = { tr: 'Karışım', en: 'Mash-up' }; + const short = (s, n) => { + const t = String(s || '').trim(); + return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; + }; + + /* Ad: kelime ve parçaları veren presetlerin adları (ilk üç ayrı ad, + kısaltılmış). Arayüz dilinde; kaynağa girmiyor. `nameOf(id)` presetin + görünen adını veriyor (yerleşiklerin adı çevrilebiliyor). */ + function nameFor(recipe, nameOf, lang) { + const seen = new Set(); + const names = []; + for (const s of SLOTS) { + const id = recipe[s]; + if (!id || seen.has(id)) continue; + seen.add(id); + const n = short(nameOf ? nameOf(id) : id, 22); + if (n) names.push(n); + if (names.length === 3) break; + } + return (WORD[lang === 'en' ? 'en' : 'tr']) + ' · ' + names.join(' × '); + } + + /* Parçası olan rastgele bir aday. Liste on binlerce preset olabilir: + süzmek yerine rastgele dokunup sınıyor, en çok `tries` kez. `avoid` + o parçada şu an duran preset — yeniden çekilince değişsin. Bulamazsa + null. */ + function pick(list, slot, rand, avoid, tries) { + const L = Array.isArray(list) ? list : []; + if (!L.length) return null; + const r = typeof rand === 'function' ? rand : Math.random; + const n = Math.max(1, tries || 400); + let fallback = null; + for (let i = 0; i < n; i++) { + const p = L[Math.floor(r() * L.length)]; + if (!p || typeof p.source !== 'string' || !has(p.source, slot)) continue; + if (p.id !== avoid) return p; + fallback = p; + } + /* Rastgele dokunuş bulamadıysa (parçası olan preset çok seyrek) liste + baştan taranıyor: aday varsa mutlaka bulunsun. */ + for (const p of L) { + if (p && typeof p.source === 'string' && p.id !== avoid && has(p.source, slot)) return p; + } + return fallback; + } + + const api = { + VERSION, SLOTS, NONE, slotOf, split, has, compose, recipeKey, idOf, nameFor, pick, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (typeof window !== 'undefined') window.SVMdMix = api; +})(); diff --git a/tests/milkdrop-mashup.test.js b/tests/milkdrop-mashup.test.js new file mode 100644 index 0000000..c3d1934 --- /dev/null +++ b/tests/milkdrop-mashup.test.js @@ -0,0 +1,247 @@ +'use strict'; +/* MilkDrop karışımı (#579): parçalar bütünüyle ve olduğu gibi. + * + * Tek presetten kurulan karışımın kendisini vermesi hiçbir şeyin + * düşmediğini gösteriyor ama bir anahtarın YANLIŞ parçaya konduğunu + * göstermiyor: altı parça aynı presetten geldiği için yanlış yere konan + * anahtar yine çıktıda. Onu farklı presetlerden kurulan karışım gösteriyor: + * her parça kendi presetininkine eşit olmalı. İkisi de burada; korpusun + * 10.332 presetiyle ölçümü ROADMAP'te. */ +const test = require('node:test'); +const assert = require('node:assert'); + +const X = require('../src/shared/milkdrop-mashup.js'); +const M = require('../src/shared/milkdrop.js'); +const G = require('../src/shared/milkdrop-generator.js'); +const BUILTINS = require('../src/shared/presets-milkdrop.js'); + +// Test presetleri: yerleşikler ve her parçası olan üretilmişler +function seeds(n, start) { + let x = (start || 0x2468ACE) >>> 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); + } + return out; +} +const GENERATED = seeds(40).map((s) => G.generate({ + energy: s % 101, warmth: (s >>> 7) % 101, density: 40 + ((s >>> 13) % 61), motion: (s >>> 19) % 101, seed: s % G.SEED_LIMIT, +}).source); +const TEXTS = BUILTINS.map((p) => p.source).concat(GENERATED); + +const VKEYS = ['milkdrop_preset_version', 'psversion', 'psversion_warp', 'psversion_comp']; +const MOTION = ['zoom', 'rot', 'cx', 'cy', 'dx', 'dy', 'warp', 'sx', 'sy', 'fwarpanimspeed', 'fwarpscale', 'fzoomexponent']; +const BLUR = ['b1n', 'b2n', 'b3n', 'b1x', 'b2x', 'b3x', 'b1ed']; + +// Bir parametrenin parçası — testin kendi, kaynağınkinden bağımsız yazımı +function classOf(k) { + if (VKEYS.indexOf(k) >= 0) return 'version'; + if (MOTION.indexOf(k) >= 0) return 'motion'; + if (k.indexOf('wavecode_') === 0) return 'waves'; + if (k.indexOf('shapecode_') === 0) return 'shapes'; + if (BLUR.indexOf(k) >= 0) return 'comp'; + return 'look'; +} +const paramsOf = (f, cls) => { + const o = {}; + for (const k of Object.keys(f.params)) if (classOf(k) === cls) o[k] = f.params[k]; + return o; +}; + +// -------------------------------------------------------------- sınıflama + +test('her anahtar tek bir parçaya ait', () => { + const cases = { + look: ['fRating', 'fDecay', 'fGammaAdj', 'fVideoEchoZoom', 'nWaveMode', 'bAdditiveWaves', 'wave_r', 'wave_x', + 'ob_size', 'ib_a', 'mv_l', 'nMotionVectorsX', 'bInvert', 'bRedBlueStereo', 'fShader', 'nWrapMode_X', 'wave_thick', 'bilinmeyen'], + motion: ['zoom', 'ROT', ' cx ', 'dx', 'warp', 'sx', 'fWarpAnimSpeed', 'fWarpScale', 'fZoomExponent', + 'per_frame_1', 'per_frame_init_3', 'PER_PIXEL_12', 'per_vertex_2'], + waves: ['wavecode_0_enabled', 'wavecode_3_r', 'wave_1_per_point7', 'wave_0_per_frame1', 'wave_2_init1'], + shapes: ['shapecode_0_sides', 'shapecode_3_border_a', 'shape_2_per_frame4', 'shape_0_init1'], + warp: ['warp_1', 'WARP_27'], + comp: ['comp_1', 'comp_40', 'b1n', 'b3x', 'b1ed'], + version: ['MILKDROP_PRESET_VERSION', 'PSVERSION', 'psversion_warp', 'PSVERSION_COMP'], + }; + for (const slot of Object.keys(cases)) { + for (const k of cases[slot]) assert.strictEqual(X.slotOf(k), slot, k); + } +}); + +// ------------------------------------------------------------- gidiş-dönüş + +function sameParts(a, b, msg) { + assert.strictEqual(a.init, b.init, msg + ' init'); + assert.strictEqual(a.perFrame, b.perFrame, msg + ' per_frame'); + assert.strictEqual(a.perPixel, b.perPixel, msg + ' per_pixel'); + assert.deepStrictEqual(a.waves, b.waves, msg + ' dalgalar'); + assert.deepStrictEqual(a.shapes, b.shapes, msg + ' şekiller'); + assert.strictEqual(a.warpShader, b.warpShader, msg + ' warp'); + assert.strictEqual(a.compShader, b.compShader, msg + ' comp'); +} + +test('tek presetten kurulan karışım presetin kendisi: hiçbir şey düşmüyor', () => { + for (const t of TEXTS) { + const all = {}; + for (const s of X.SLOTS) all[s] = t; + const out = M.parseMilk(X.compose(all)); + const src = M.parseMilk(t); + sameParts(out, src, 'tek preset'); + for (const cls of ['look', 'motion', 'waves', 'shapes', 'comp']) { + assert.deepStrictEqual(paramsOf(out, cls), paramsOf(src, cls), cls); + } + } +}); + +test('farklı presetlerden: her parça kendi presetininkine eşit', () => { + /* Yanlış parçaya konan bir anahtar burada görünüyor: tek presetten + kurulan karışımda görünmüyordu. */ + const pickT = seeds(300, 0x51A); + for (let i = 0; i < 60; i++) { + const d = {}; + X.SLOTS.forEach((s, j) => { d[s] = TEXTS[pickT[i * 6 + j] % TEXTS.length]; }); + const out = M.parseMilk(X.compose(d)); + const p = {}; + for (const s of X.SLOTS) p[s] = M.parseMilk(d[s]); + assert.strictEqual(out.init, p.motion.init); + assert.strictEqual(out.perFrame, p.motion.perFrame); + assert.strictEqual(out.perPixel, p.motion.perPixel); + assert.deepStrictEqual(out.waves, p.waves.waves); + assert.deepStrictEqual(out.shapes, p.shapes.shapes); + assert.strictEqual(out.warpShader, p.warp.warpShader); + assert.strictEqual(out.compShader, p.comp.compShader); + assert.deepStrictEqual(paramsOf(out, 'look'), paramsOf(p.look, 'look')); + assert.deepStrictEqual(paramsOf(out, 'motion'), paramsOf(p.motion, 'motion')); + assert.deepStrictEqual(paramsOf(out, 'waves'), paramsOf(p.waves, 'waves')); + assert.deepStrictEqual(paramsOf(out, 'shapes'), paramsOf(p.shapes, 'shapes')); + assert.deepStrictEqual(paramsOf(out, 'comp'), paramsOf(p.comp, 'comp')); + } +}); + +test('satırlar olduğu gibi: numaralar değişmiyor, yeni satır yazılmıyor', () => { + const [a, b] = [GENERATED[0], GENERATED[1]]; + const out = X.compose({ look: a, motion: b, waves: a, shapes: b, warp: a, comp: b }); + const lines = out.split('\n').filter((l) => l && l[0] !== '[' && !/^(MILKDROP_PRESET_VERSION|PSVERSION)/.test(l)); + const pool = new Set(a.split('\n').concat(b.split('\n')).map((l) => l.trim())); + for (const l of lines) assert.ok(pool.has(l), 'kaynakta olmayan satır: ' + l); +}); + +test('sürüm satırları shader\'ı veren presetten', () => { + const md2 = GENERATED.find((t) => /warp_1=/.test(t) && /comp_1=/.test(t)); + const md1 = BUILTINS[0].source; + assert.ok(md2 && !/PSVERSION/.test(md1)); + const ver = (t) => { + const f = M.parseMilk(t); + return [f.params.milkdrop_preset_version, f.params.psversion, f.params.psversion_warp, f.params.psversion_comp]; + }; + // Görünüm MilkDrop 1, shader'lar MilkDrop 2 presetinden: satırlar yazılıyor + assert.deepStrictEqual(ver(X.compose({ look: md1, motion: md1, waves: md1, shapes: md1, warp: md2, comp: md2 })), [201, 2, 2, 2]); + // Shader yok: MilkDrop 1 dosyası + const none = X.compose({ look: md1, motion: md2, waves: md2, shapes: md2, warp: X.NONE, comp: X.NONE }); + assert.ok(!/PSVERSION|MILKDROP_PRESET_VERSION/.test(none), none.slice(0, 80)); + assert.ok(!/^(warp|comp)_\d+=/m.test(none)); + /* Bulanıklık aralıkları birleştirme parçasında ama shader değil: shader'ı + olmayan MilkDrop 1 presetine sürüm satırı yazılmıyor (korpusta 308 + preset böyleydi) */ + const blurOnly = '[preset00]\nfDecay=0.9\nb1n=0.000\nb1x=1.000\nper_frame_1=zoom = 1.01;\n'; + assert.ok(!/PSVERSION|MILKDROP_PRESET_VERSION/.test(X.compose({ look: blurOnly }))); + assert.deepStrictEqual(ver(X.compose({ look: md2, comp: blurOnly })).slice(2), [2, 0]); + /* Shader'ı olmayan bir MilkDrop 2 dosyası kendi sürüm satırlarını + koruyor: tek presetten kurulan karışım kendisini birebir vermeli */ + const md2Plain = 'MILKDROP_PRESET_VERSION=201\nPSVERSION=2\nPSVERSION_WARP=0\nPSVERSION_COMP=0\n[preset00]\nfDecay=0.95\nper_frame_1=zoom = 1.01;\n'; + assert.deepStrictEqual(ver(X.compose({ look: md2Plain, motion: md2Plain, waves: md2Plain, shapes: md2Plain, warp: md2Plain, comp: md2Plain })), [201, 2, 0, 0]); + // Yalnız birleştirme: warp sürümü 0 + assert.deepStrictEqual(ver(X.compose({ look: md1, motion: md1, waves: md1, shapes: md1, warp: X.NONE, comp: md2 })), [201, 2, 0, 2]); + // Presetin kendi sürümü korunuyor (ps_3_0 shader'ı 2 ile okunmaz) + const v3 = md2.replace('PSVERSION=2', 'PSVERSION=3').replace('PSVERSION_WARP=2', 'PSVERSION_WARP=3').replace('PSVERSION_COMP=2', 'PSVERSION_COMP=3'); + assert.deepStrictEqual(ver(X.compose({ look: md1, motion: md1, waves: md1, shapes: md1, warp: v3, comp: md2 })), [201, 3, 3, 2]); +}); + +test('"yok" shader parçası bulanıklık aralıklarını da götürüyor', () => { + const withBlur = '[preset00]\nfDecay=0.9\nb1n=0.1\nb1x=0.8\ncomp_1=`shader_body { ret = GetBlur1(uv); }\n'; + const out = M.parseMilk(X.compose({ look: withBlur, comp: X.NONE })); + assert.strictEqual(out.params.b1n, undefined); + assert.strictEqual(out.params.fdecay, 0.9); + const kept = M.parseMilk(X.compose({ look: '[preset00]\nfDecay=0.5\n', comp: withBlur })); + assert.strictEqual(kept.params.b1n, 0.1); + assert.strictEqual(kept.params.fdecay, 0.5); +}); + +// ------------------------------------------------------------- parça var mı + +test('"parçası var mı" ayrıştırıcıyla aynı cevabı veriyor', () => { + const parsedHas = (t, slot) => { + const f = M.parseMilk(t); + const enabled = (pre) => Object.keys(f.params).some((k) => new RegExp('^' + pre + '_\\d+_enabled$').test(k) && + typeof f.params[k] === 'number' && f.params[k] !== 0); + if (slot === 'look') return true; + if (slot === 'motion') return !!(f.init.trim() || f.perFrame.trim() || f.perPixel.trim()); + if (slot === 'waves') return enabled('wavecode'); + if (slot === 'shapes') return enabled('shapecode'); + if (slot === 'warp') return !!f.warpShader.trim(); + return !!f.compShader.trim(); + }; + const edge = [ + '[preset00]\nper_frame_1=// yalnız yorum\n', + '[preset00]\n PER_FRAME_1=zoom = 1.01;\n', + '[preset00]\nper_vertex_1=rot = rot + 0.01;\n', + '[preset00]\nwavecode_0_enabled=0\nwave_0_per_point1=x = sample;\n', + '[preset00]\nwavecode_2_enabled=1.000\n', + '[preset00]\nWAVECODE_1_ENABLED=1\n', + '[preset00]\nwavecode_0_enabled=evet\n', + '[preset00]\nshapecode_3_enabled=0.000\nshapecode_1_enabled=2\n', + // Korpusta: aynı anahtar iki kez, ayrıştırıcı sonuncuyu tutuyor + '[preset00]\nshapecode_2_enabled=1\nshapecode_2_enabled=0\n', + '[preset00]\nwavecode_0_enabled=0\r\nwavecode_0_enabled=1\r\n', + '[preset00]\nwarp_1=`\nwarp_2=` \n', + '[preset00]\nwarp_1=`shader_body\ncomp_1=\n', + '[preset00]\ncomp_1=`ret = 1;\n', + '', + ]; + for (const t of edge.concat(TEXTS)) { + for (const s of X.SLOTS) { + assert.strictEqual(X.has(t, s), parsedHas(t, s), s + ': ' + JSON.stringify(t.slice(0, 60))); + } + } +}); + +// --------------------------------------------------------- kimlik ve ad + +test('kimlik tariften: aynı tarif aynı kimlik, depo adıyla uyumlu', () => { + const r = { look: 'a', motion: 'b', waves: 'c', shapes: 'a', warp: '', comp: 'd' }; + const id = X.idOf(r); + assert.match(id, /^md_mix1_[0-9a-f]{16}$/); + assert.strictEqual(X.idOf(Object.assign({}, r)), id); + assert.notStrictEqual(X.idOf(Object.assign({}, r, { warp: 'b' })), id); + assert.notStrictEqual(X.idOf(Object.assign({}, r, { look: 'b', motion: 'a' })), id, 'parçanın yeri de kimlikte'); + assert.strictEqual(X.recipeKey(r), 'look:a|motion:b|waves:c|shapes:a|warp:|comp:d'); +}); + +test('ad: kelime ve ilk üç ayrı preset, uzunlar kısaltılmış', () => { + const names = { a: 'Alfa', b: 'Beta', c: 'Gamma Delta Epsilon Zeta Eta Theta', d: 'Delta' }; + const r = { look: 'a', motion: 'b', waves: 'a', shapes: 'c', warp: '', comp: 'd' }; + assert.strictEqual(X.nameFor(r, (id) => names[id], 'tr'), 'Karışım · Alfa × Beta × Gamma Delta Epsilon Z…'); + assert.strictEqual(X.nameFor(r, (id) => names[id], 'en'), 'Mash-up · Alfa × Beta × Gamma Delta Epsilon Z…'); +}); + +// ------------------------------------------------------------------- aday + +test('aday yalnız o parçası olan presetlerden, tohumla aynı sonuç', () => { + const list = TEXTS.map((t, i) => ({ id: 'p' + i, source: t })); + const withWarp = new Set(list.filter((p) => X.has(p.source, 'warp')).map((p) => p.id)); + assert.ok(withWarp.size > 5 && withWarp.size < list.length); + const rand = (s) => { let x = s; return () => { x = (x * 1103515245 + 12345) % 2147483648; return x / 2147483648; }; }; + for (let s = 1; s < 40; s++) { + const p = X.pick(list, 'warp', rand(s)); + assert.ok(p && withWarp.has(p.id), String(p && p.id)); + assert.strictEqual(X.pick(list, 'warp', rand(s)).id, p.id, 'aynı tohum aynı aday'); + const q = X.pick(list, 'warp', rand(s), p.id); + assert.notStrictEqual(q.id, p.id, 'yeniden çekince değişmeli'); + } + assert.strictEqual(X.pick([], 'look', Math.random), null); + // Seyrek aday rastgele dokunuşta bulunmasa da taranıp bulunuyor + const rare = list.map((p, i) => (i === 7 ? p : { id: p.id, source: '[preset00]\nfDecay=0.9\n' })); + const found = X.pick(rare, 'motion', () => 0, null, 3); + assert.strictEqual(found && found.id, 'p7'); + assert.strictEqual(X.pick(rare.filter((p) => p.id !== 'p7'), 'motion', Math.random), null); +}); From e8ba91a62d54b473cfd00ccf983f22563236856f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 23 Sep 2026 06:28:33 +0300 Subject: [PATCH 2/4] feat(admin): mash-ups from the library in the generator card - New Mash-up draws each part from the presets the MilkDrop panel's list shows (search and filter apply), only from presets with that part. Warp and composite are sometimes "none", so the look's fixed pipeline still gets a turn. - One part can be drawn again, or all six can start from the preset on screen; an unsaved preview never gives a part. - A recipe history steps back through mash-ups and skips a recipe whose preset was deleted. The id comes from the recipe, so saving twice leaves one file. - Every new string has an English entry. Refs #579 --- src/admin/admin.js | 2 +- src/admin/index.html | 1 + src/admin/milkdrop-gen.js | 217 ++++++++++++++++++++- src/admin/milkdrop-panel.js | 7 +- src/admin/studio.css | 5 + src/shared/i18n.js | 19 +- tests/milkdrop-mashup-panel.test.js | 290 ++++++++++++++++++++++++++++ 7 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 tests/milkdrop-mashup-panel.test.js diff --git a/src/admin/admin.js b/src/admin/admin.js index 0dd6c48..bb8eee5 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -2831,7 +2831,7 @@ 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.', + desc: 'Enerji, sıcaklık, yoğunluk ve hareketten özgün bir MilkDrop preseti yazar ya da kütüphanenizdeki presetlerin parçalarını karıştırır. Tamamen çevrimdışı; beğendiğinizi kütüphaneye kaydedin.', controls: [{ type: 'mdgenpanel' }], }, { diff --git a/src/admin/index.html b/src/admin/index.html index efc13ac..de7dc92 100644 --- a/src/admin/index.html +++ b/src/admin/index.html @@ -327,6 +327,7 @@

Ayarlar

+ diff --git a/src/admin/milkdrop-gen.js b/src/admin/milkdrop-gen.js index fd41b22..c55c706 100644 --- a/src/admin/milkdrop-gen.js +++ b/src/admin/milkdrop-gen.js @@ -76,8 +76,12 @@ return !!(S && typeof S.user === 'function' && S.user().some((p) => p && p.id === id)); }; - async function save() { - const r = last; + // Üreticinin son sonucu; karışımınki `saveMix` + function save() { + return saveResult(last); + } + + async function saveResult(r) { if (!r || saving) return; if (!window.api || !window.api.savePreset) { P().toast(tr('Kaydetme kullanılamıyor.'), 'err'); @@ -107,6 +111,209 @@ } } + // -------------------------------------------------------------------- + // Kütüphaneden karışım (shared/milkdrop-mashup.js) + // -------------------------------------------------------------------- + /* Parçalar MilkDrop panelinin listesinde GÖRÜNEN presetlerden çekiliyor + (arama ve süzgeç burada da geçerli) ve yalnız o parçası olanlardan. + Warp ve birleştirme bazen "yok" çıkıyor: kütüphanenin çoğu MilkDrop 2 + presetiyse her karışım iki shader alırdı ve görünümün sabit yolu + (yankı, gama, bayraklar) hiç çalışmazdı. + + Geçmiş tarifleri tutuyor (parça -> preset kimliği), metni değil: ◀/▶ + karışımı aynı parçalardan yeniden kuruyor. Parçalarından biri silinmiş + bir tarif atlanıyor, MilkDrop panelinin ◀/▶'ı gibi. */ + const X = () => window.SVMdMix; + const NONE_CHANCE = 0.12; + const SLOT_LABELS = { + look: 'Görünüm', motion: 'Hareket', waves: 'Dalgalar', shapes: 'Şekiller', warp: 'Warp', comp: 'Birleştirme', + }; + let mix = null; + let mixLast = null; + let mixHist = null; + const mixRecipes = new Map(); + function mixHistory() { + const C = window.SVMilkdropCycle; + if (!mixHist && C && C.History) mixHist = new C.History(64); + return mixHist; + } + const byId = (id) => (MP() && MP().presetById ? MP().presetById(id) : null); + + // Bir parçanın yeni presetini çeker; bulunamazsa null + function draw(list, slot, avoid) { + if ((slot === 'warp' || slot === 'comp') && avoid !== X().NONE && Math.random() < NONE_CHANCE) return X().NONE; + const p = X().pick(list, slot, Math.random, avoid); + if (p) return p.id; + return slot === 'warp' || slot === 'comp' ? X().NONE : null; + } + + /* Tarifi kurar ve yükler. `record` yeni bir karışım mı (geçmişe girer) + yoksa geçmişte gezinme mi. Parçalarından biri artık yoksa false. */ + function showMix(recipe, record) { + const donors = {}; + for (const s of X().SLOTS) { + if (recipe[s] === X().NONE) { donors[s] = X().NONE; continue; } + const p = byId(recipe[s]); + if (!p || typeof p.source !== 'string') return false; + donors[s] = p.source; + } + const nameOf = (id) => { const p = byId(id); return p ? tr(p.name || id) : id; }; + const r = { + id: X().idOf(recipe), name: X().nameFor(recipe, nameOf, lang()), + source: X().compose(donors), recipe: Object.assign({}, recipe), + }; + mix = r.recipe; + mixLast = r; + if (record) { + const key = X().recipeKey(recipe); + mixRecipes.set(key, r.recipe); + const h = mixHistory(); + if (h) h.note(key); + } + 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(); + } + return true; + } + + function mixList() { + const panel = MP(); + const list = panel && panel.visibleList ? panel.visibleList() : []; + if (!list.length) P().toast(tr('Listede preset yok.'), 'err'); + return list; + } + + // Altı parçanın hepsi yeniden + function rollAll() { + if (!X()) return; + const list = mixList(); + if (!list.length) return; + const look = draw(list, 'look', null); + if (!look) return; + const recipe = { look }; + for (const s of X().SLOTS) { + if (s === 'look') continue; + const id = draw(list, s, null); + // Parçası olan aday yoksa görünümün presetinden (belki boş) gelir + recipe[s] = id === null ? look : id; + } + showMix(recipe, true); + } + + // Tek parça yeniden; ötekiler yerinde + function rollSlot(slot) { + if (!X() || !mix) return; + const list = mixList(); + if (!list.length) return; + const id = draw(list, slot, mix[slot]); + if (id === null) { + P().toast(tr('Listede bu parçası olan başka preset yok.'), 'err'); + return; + } + showMix(Object.assign({}, mix, { [slot]: id }), true); + } + + /* Ekrandaki presetten başlar: altı parçanın hepsi ondan, sonra tek tek + değiştirilir. Ekrandaki listede olmalı — kaydedilmemiş bir önizleme ya + da karışım parça veremez. */ + function fromScreen() { + if (!X()) return; + const panel = MP(); + const cfg = P().cfg(); + const id = panel && panel.liveId && cfg.milkdrop ? panel.liveId(cfg.milkdrop) : ''; + const p = id ? byId(id) : null; + if (!p) { + P().toast(tr('Önce MilkDrop listesinden bir preset seçin.'), 'err'); + return; + } + const recipe = {}; + for (const s of X().SLOTS) { + recipe[s] = (s === 'warp' || s === 'comp') && !X().has(p.source, s) ? X().NONE : p.id; + } + showMix(recipe, true); + } + + function mixNav(dir) { + const h = mixHistory(); + if (!h) return; + for (let key = dir < 0 ? h.back() : h.forward(); key; key = dir < 0 ? h.back() : h.forward()) { + const recipe = mixRecipes.get(key); + if (recipe && showMix(recipe, false)) return; + } + P().rerender(); + } + + function saveMix() { + return saveResult(mixLast); + } + + function mixSection() { + const el = P().el; + const nodes = [el('h4', { class: 'mdgen-sub', text: 'Kütüphaneden Karışım' })]; + const h = mixHistory(); + nodes.push(el('div', { class: 'gen-actions' }, [ + el('button', { + class: 'btn primary', type: 'button', text: '🎲 Yeni Karışım', + title: 'Her parçayı listede görünen presetlerden rastgele çeker', + onclick: () => rollAll(), + }), + el('button', { + class: 'btn', type: 'button', text: '📌 Ekrandakinden Başla', + title: 'Altı parçanın hepsini ekrandaki presetten alır; sonra tek tek değiştirin', + onclick: () => fromScreen(), + }), + ])); + if (mix) { + for (const s of X().SLOTS) { + const id = mix[s]; + const p = id && id !== X().NONE ? byId(id) : null; + nodes.push(el('div', { class: 'mdmix-row' }, [ + el('span', { class: 'mdmix-slot', text: SLOT_LABELS[s] }), + // Presetin adı kendi adı; yerleşiklerin adı sözlükte + el('span', { class: 'mdmix-name' + (p ? '' : ' dim-hint'), text: p ? tr(p.name || p.id) : (id === X().NONE ? 'Yok' : '—') }), + el('button', { + class: 'btn ghost small', type: 'button', text: '🎲', + title: 'Bu parçayı yeniden çek', 'aria-label': tr('Bu parçayı yeniden çek'), + onclick: () => rollSlot(s), + }), + ])); + } + } + nodes.push(el('div', { class: 'gen-actions' }, [ + el('button', { + class: 'btn', type: 'button', text: '◀', title: 'Önceki karışım', + disabled: !(h && h.canBack()), onclick: () => mixNav(-1), + }), + el('button', { + class: 'btn', type: 'button', text: '▶', title: 'Sonraki karışım', + disabled: !(h && h.canForward()), onclick: () => mixNav(1), + }), + el('button', { + class: 'btn', type: 'button', text: saving ? 'Kaydediliyor…' : '💾 Kütüphaneye Kaydet', + disabled: !mixLast || saving, + onclick: () => saveMix(), + }), + ])); + if (mixLast) { + const kept = inLibrary(mixLast.id); + nodes.push(el('div', { class: 'studio-note' }, [ + el('div', { class: 'gen-pair' }, [ + el('span', { class: 'dim-hint', text: 'Son Karışım' }), + el('span', { class: 'md-cur mdgen-name', text: mixLast.name }), + el('span', { class: 'dim-hint', text: 'Durum' }), + el('span', { class: kept ? 'md-ok' : 'dim-hint', text: kept ? '✓ Kütüphanede' : 'Önizleme — kaydedilmedi' }), + ]), + ])); + } + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Parçalar MilkDrop panelinin listesinde görünen presetlerden çekilir; arama ve süzgeç burada da geçerli. Her parça bütünüyle tek bir presetten gelir ve satırları olduğu gibi kopyalanır. Bir presetin shader\'ı başka bir presetin denklemlerine göre yazılmış olabilir, yani sonuç şaşırtabilir. Warp ve birleştirme bazen "yok" çıkar: o zaman görünümün kendi yankısı ve gaması çalışır. ◀ ▶ önceki karışımlara döner.', + })); + return nodes; + } + function axisRow(key, label, lo, hi) { const el = P().el; const val = el('span', { class: 'val', text: String(axes[key]) }); @@ -187,9 +394,13 @@ 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ı.', })); + if (X()) for (const n of mixSection()) nodes.push(n); return el('div', { class: 'mdgen-panel' }, nodes); } - window.SVMdGenPanel = { panel, regenerate, save, fromCode, state: () => ({ axes: Object.assign({}, axes), seed, last }) }; + window.SVMdGenPanel = { + panel, regenerate, save, fromCode, rollAll, rollSlot, fromScreen, mixNav, saveMix, + state: () => ({ axes: Object.assign({}, axes), seed, last, mix: mix && Object.assign({}, mix), mixLast }), + }; 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 9524b99..bf4fe29 100644 --- a/src/admin/milkdrop-panel.js +++ b/src/admin/milkdrop-panel.js @@ -1784,9 +1784,14 @@ /* load ve pointStackAtMilkdrop testler icin de disa aciliyor: preset secmenin sahneyi GERCEKTEN degistirdigi, panelin arayuzunu kurmadan sinanabilsin. */ + /* Karışım (#579) parçalarını buradan çekiyor: listede GÖRÜNENLER, yani + arama ve süzgeç orada da geçerli. Kaydedilmemiş bir önizleme listede + olmadığı için hiçbir zaman parça vermiyor. `presetById` bütün listede + arıyor: geçmişteki bir karışım süzgeç değişse de geri getirilebilsin. */ + const visibleList = () => visible(P().cfg()); window.SVMilkdropPanel = { panel, init, refresh, load, pointStackAtMilkdrop, noteLive, liveId, history, act, fillStars, - spriteTargets, preview, adopt, + spriteTargets, preview, adopt, visibleList, presetById: byId, }; if (typeof module !== 'undefined' && module.exports) { module.exports = window.SVMilkdropPanel; diff --git a/src/admin/studio.css b/src/admin/studio.css index e6ad8b6..f79bdc7 100644 --- a/src/admin/studio.css +++ b/src/admin/studio.css @@ -203,6 +203,11 @@ input.p-in[type='color'] { padding: 2px; height: 28px; width: 46px; flex: none; /* 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; } +/* Kütüphaneden karışım: parça satırları (ad uzunsa kısalıyor) */ +.mdgen-sub { margin: 14px 0 6px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted2); font-weight: 600; } +.mdmix-row { display: flex; align-items: center; gap: 8px; margin: 3px 0; font-size: 11.5px; } +.mdmix-slot { flex: 0 0 84px; color: var(--muted2); } +.mdmix-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .st-num { font-variant-numeric: tabular-nums; } .st-sep { opacity: .5; } diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 304619a..4b8e2e0 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1734,8 +1734,8 @@ // '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.', + 'Enerji, sıcaklık, yoğunluk ve hareketten özgün bir MilkDrop preseti yazar ya da kütüphanenizdeki presetlerin parçalarını karıştırır. Tamamen çevrimdışı; beğendiğinizi kütüphaneye kaydedin.': + 'Writes an original MilkDrop preset from energy, warmth, density and motion, or mixes parts of the presets in your library. Fully offline; save the ones you like to the library.', 'coşkun': 'energetic', 'seyrek': 'sparse', 'yoğun': 'dense', @@ -1765,6 +1765,21 @@ 'Son Üretilen': 'Last Generated', '✓ Kütüphanede': '✓ In the library', 'Önizleme — kaydedilmedi': 'Preview — not saved', + // Kütüphaneden karışım (#579); 'Görünüm', 'Yok' ve '◀'/'▶' zaten var + 'Kütüphaneden Karışım': 'Mash-up from Your Library', + '🎲 Yeni Karışım': '🎲 New Mash-up', + 'Her parçayı listede görünen presetlerden rastgele çeker': 'Draws every part at random from the presets shown in the list', + '📌 Ekrandakinden Başla': '📌 Start from the Preset on Screen', + 'Altı parçanın hepsini ekrandaki presetten alır; sonra tek tek değiştirin': 'Takes all six parts from the preset on screen; then change them one by one', + 'Bu parçayı yeniden çek': 'Draw this part again', + 'Önceki karışım': 'Previous mash-up', + 'Sonraki karışım': 'Next mash-up', + 'Son Karışım': 'Last Mash-up', + 'Listede preset yok.': 'The list has no presets.', + 'Listede bu parçası olan başka preset yok.': 'No other preset in the list has this part.', + 'Önce MilkDrop listesinden bir preset seçin.': 'Choose a preset from the MilkDrop list first.', + 'Parçalar MilkDrop panelinin listesinde görünen presetlerden çekilir; arama ve süzgeç burada da geçerli. Her parça bütünüyle tek bir presetten gelir ve satırları olduğu gibi kopyalanır. Bir presetin shader\'ı başka bir presetin denklemlerine göre yazılmış olabilir, yani sonuç şaşırtabilir. Warp ve birleştirme bazen "yok" çıkar: o zaman görünümün kendi yankısı ve gaması çalışır. ◀ ▶ önceki karışımlara döner.': + 'Parts are drawn from the presets shown in the MilkDrop panel’s list; its search and filter apply here too. Each part comes whole from one preset and its lines are copied as they are. A preset’s shader may have been written for another preset’s equations, so the result can surprise you. Warp and composite sometimes come out as “none”: then the look’s own echo and gamma run. ◀ ▶ go back to earlier mash-ups.', '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.', diff --git a/tests/milkdrop-mashup-panel.test.js b/tests/milkdrop-mashup-panel.test.js new file mode 100644 index 0000000..7f737d0 --- /dev/null +++ b/tests/milkdrop-mashup-panel.test.js @@ -0,0 +1,290 @@ +'use strict'; +/* Kütüphaneden karışım, arayüz (#579). + * + * Parçalar MilkDrop panelinin listesinde GÖRÜNEN presetlerden: arama + * gerçek arama kutusundan yazılıyor, yani `visibleList` bağlantısı da + * sınanıyor. Kaydedilmemiş bir önizleme (üretilen preset ya da karışımın + * kendisi) hiçbir zaman parça vermiyor. */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +require('../src/shared/defaults.js'); +window.SVMilkdropCycle = require('../src/shared/milkdrop-cycle.js'); +window.SVMilkdropLibrary = require('../src/shared/milkdrop-library.js'); +const X = require('../src/shared/milkdrop-mashup.js'); +const G = require('../src/shared/milkdrop-generator.js'); +const M0 = require('../src/shared/milkdrop.js'); +const BUILTINS = require('../src/shared/presets-milkdrop.js'); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Her parçası olan üretilmiş presetler, bir MilkDrop 1 preseti ve yalnız görünüm +function richSources(n) { + const out = []; + for (let s = 1; out.length < n && s < 5000; s++) { + const r = G.generate({ energy: 60, warmth: (s * 37) % 101, density: 90, motion: 50, seed: s }); + if (r.parts.shader === 'ikisi' && r.parts.waves.length && r.parts.shapes.length) out.push(r.source); + } + return out; +} +const RICH = richSources(6); +const LOOK_ONLY = '[preset00]\nfDecay=0.900\nzoom=1.010\n'; +function library() { + return [ + { id: 'u_a1', name: 'Alfa 1', source: RICH[0] }, + { id: 'u_a2', name: 'Alfa 2', source: RICH[1] }, + { id: 'u_b1', name: 'Beta 1', source: RICH[2] }, + { id: 'u_b2', name: 'Beta 2', source: RICH[3] }, + { id: 'u_b3', name: 'Beta 3', source: BUILTINS[0].source }, + { id: 'u_a3', name: 'Alfa 3', source: RICH[4] }, + { id: 'u_g', name: 'Gama', source: RICH[5] }, + { id: 'u_d', name: 'Delta', source: LOOK_ONLY }, + ].map((p) => Object.assign({ kind: 'milkdrop' }, p)); +} + +function mkNode(tag, props, kids) { + const n = { tag, attrs: {}, kids: [], on: {}, text: '', className: '', props: props || {} }; + for (const [k, v] of Object.entries(props || {})) { + if (k === 'class') n.className = v; + else if (k === 'text') n.text = v; + else if (k.indexOf('on') === 0 && typeof v === 'function') n.on[k.slice(2)] = v; + else if (v !== false && v != null) n.attrs[k] = String(v); + } + n.appendChild = (c) => { n.kids.push(c); return c; }; + n.addEventListener = (ev, f) => { n.on[ev] = f; }; + n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null); + n.setAttribute = (k, v) => { n.attrs[k] = String(v); }; + n.removeAttribute = (k) => { delete n.attrs[k]; }; + // Arama kutusu yeniden çizilince odağı geri koyuyor + n.focus = () => {}; + n.setSelectionRange = () => {}; + n.classList = { + add: (...c) => { const s = new Set(n.className.split(/\s+/).filter(Boolean)); c.forEach((x) => s.add(x)); n.className = [...s].join(' '); }, + remove: (...c) => { n.className = n.className.split(/\s+/).filter((x) => x && c.indexOf(x) < 0).join(' '); }, + contains: (c) => n.className.split(/\s+/).indexOf(c) >= 0, + toggle: (c, on) => { if (on) n.classList.add(c); else n.classList.remove(c); }, + }; + const match = (x, sel) => (sel[0] === '.' ? x.className && x.className.split(/\s+/).indexOf(sel.slice(1)) >= 0 : x.tag === sel); + n.querySelectorAll = (sel) => { const out = []; walk(n, (x) => { if (x !== n && match(x, sel)) out.push(x); }); return out; }; + n.querySelector = (sel) => n.querySelectorAll(sel)[0] || null; + Object.defineProperty(n, 'textContent', { get: () => n.text, set: (v) => { n.text = v; n.kids = []; } }); + (kids || []).forEach((c) => c && n.kids.push(c)); + return n; +} +function walk(n, f) { + if (!n || typeof n !== 'object') return; + f(n); + (n.kids || []).forEach((k) => walk(k, f)); + if (n.node) walk(n.node, f); +} +const findAll = (root, pred) => { const out = []; walk(root, (x) => { if (pred(x)) out.push(x); }); return out; }; + +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 = window.SV.defaultConfig(); + const root = mkNode('div', { id: 'sections' }); + const lib = library(); + const saved = []; + const toasts = []; + global.localStorage = { getItem: () => null, setItem() {} }; + global.document = { + querySelector: (sel) => root.querySelector(sel), + getElementById: (id) => (id === 'sections' ? root : null), + activeElement: null, + }; + window.SVMdGen = G; + window.SVMdMix = X; + window.SVPresets = undefined; + window.SVMdFollow = null; + window.SVI18n = undefined; + window.SVMilkdropBuiltins = []; + window.api = { + listPresets: () => Promise.resolve(lib.map((p) => Object.assign({}, p))), + savePreset: (p) => { saved.push(p); return Promise.resolve({ ok: true, preset: Object.assign({}, p, { updatedAt: 1 }) }); }, + onMilkdropThumb() {}, onPresetsDelta() {}, + }; + window.SVPanel = { + cfg: () => cfg, apply() {}, rerender() {}, el: mkNode, + row: (label, node) => ({ label, node, kids: [] }), + toast: (m, k) => toasts.push([m, k]), confirm: () => Promise.resolve(true), + }; + window.SVScenePanels = { miniSlider: (label) => ({ label, kids: [] }) }; + const M = require('../src/admin/milkdrop-panel.js'); + M.init(); + await wait(5); + const GP = require('../src/admin/milkdrop-gen.js'); + // MilkDrop panelinin arama kutusu: gerçek girdi olayıyla + const search = (q) => { + root.kids = [M.panel()]; + const box = root.querySelector('.md-search'); + assert.ok(box, 'arama kutusu yok'); + box.on.input({ target: { value: q, selectionStart: q.length } }); + }; + return { M, GP, cfg, lib, saved, toasts, search, root }; +} + +const donorsOf = (GP) => GP.state().mix; +const byId = (lib, id) => lib.find((p) => p.id === id); + +test('parçalar yalnız listede GÖRÜNEN presetlerden', async () => { + const { M, GP, lib, search } = await fresh(); + search('beta'); + const visible = M.visibleList().map((p) => p.id).sort(); + assert.deepStrictEqual(visible, ['u_b1', 'u_b2', 'u_b3']); + for (let i = 0; i < 25; i++) { + GP.rollAll(); + const mix = donorsOf(GP); + for (const s of X.SLOTS) { + if (mix[s] === X.NONE) continue; + assert.ok(visible.indexOf(mix[s]) >= 0, s + ' görünmeyen presetten: ' + mix[s]); + assert.ok(byId(lib, mix[s])); + } + } +}); + +test('her parça o parçası olan presetten; shader parçası bazen "yok"', async () => { + const { GP, lib } = await fresh(); + let none = 0; + for (let i = 0; i < 80; i++) { + GP.rollAll(); + const mix = donorsOf(GP); + for (const s of X.SLOTS) { + if (mix[s] === X.NONE) { assert.ok(s === 'warp' || s === 'comp', s); none++; continue; } + assert.ok(X.has(byId(lib, mix[s]).source, s), s + ' parçası olmayan presetten: ' + mix[s]); + } + } + assert.ok(none > 0, 'warp ya da birleştirme hiç "yok" çıkmadı'); +}); + +test('karışımın metni parçalarının presetlerinden, önizleme kaydedilmiyor', async () => { + const { GP, cfg, lib, saved } = await fresh(); + GP.rollAll(); + const { mix, mixLast } = GP.state(); + assert.strictEqual(cfg.milkdrop.presetId, mixLast.id); + assert.match(mixLast.id, /^md_mix1_[0-9a-f]{16}$/); + assert.strictEqual(mixLast.id, X.idOf(mix)); + assert.strictEqual(cfg.milkdrop.source, mixLast.source); + const f = M0.parseMilk(mixLast.source); + assert.strictEqual(f.perFrame, M0.parseMilk(byId(lib, mix.motion).source).perFrame); + assert.deepStrictEqual(f.waves, M0.parseMilk(byId(lib, mix.waves).source).waves); + assert.strictEqual(f.warpShader, mix.warp === X.NONE ? '' : M0.parseMilk(byId(lib, mix.warp).source).warpShader); + assert.deepStrictEqual(saved, []); +}); + +test('tek parça yeniden çekilince ötekiler yerinde kalıyor', async () => { + const { GP } = await fresh(); + GP.rollAll(); + for (const s of X.SLOTS) { + const before = donorsOf(GP); + GP.rollSlot(s); + const after = donorsOf(GP); + for (const o of X.SLOTS) if (o !== s) assert.strictEqual(after[o], before[o], s + ' çekilince ' + o + ' değişti'); + assert.notStrictEqual(after[s], before[s], s + ' değişmedi'); + } +}); + +test('ekrandakinden başla: altı parça ondan; kaydedilmemiş önizleme parça vermiyor', async () => { + const { M, GP, lib, toasts } = await fresh(); + M.preview(byId(lib, 'u_b3')); + GP.fromScreen(); + const mix = donorsOf(GP); + // u_b3 MilkDrop 1 preseti: shader parçaları "yok" + assert.deepStrictEqual(mix, { look: 'u_b3', motion: 'u_b3', waves: 'u_b3', shapes: 'u_b3', warp: X.NONE, comp: X.NONE }); + // Ekranda şimdi karışım var: listede değil, parça veremez + const n = toasts.length; + GP.fromScreen(); + assert.strictEqual(toasts.length, n + 1); + assert.strictEqual(toasts[n][1], 'err'); + assert.deepStrictEqual(donorsOf(GP), mix, 'karışım değişmemeli'); + // Üretilen önizleme de öyle + GP.regenerate(); + GP.fromScreen(); + assert.strictEqual(toasts[toasts.length - 1][1], 'err'); + // Karışımın kimliği listede yok: hiçbir zaman aday olamıyor + GP.rollAll(); + assert.ok(!M.visibleList().some((p) => /^md_(mix|gen)/.test(p.id))); +}); + +test('geçmiş: ◀ ▶ karışımı parçalarından yeniden kuruyor, silinen parçası olanı atlıyor', async () => { + const { M, GP, cfg, lib } = await fresh(); + // Rastgelelik yok: her karışım ekrandaki tek bir presetten + const ids = []; + for (const p of ['u_a1', 'u_a2', 'u_b1', 'u_b2']) { + M.preview(byId(lib, p)); + GP.fromScreen(); + ids.push(cfg.milkdrop.presetId); + } + assert.strictEqual(new Set(ids).size, 4); + GP.mixNav(-1); + assert.strictEqual(cfg.milkdrop.presetId, ids[2]); + GP.mixNav(-1); + assert.strictEqual(cfg.milkdrop.presetId, ids[1]); + GP.mixNav(1); + assert.strictEqual(cfg.milkdrop.presetId, ids[2]); + GP.mixNav(1); + assert.strictEqual(cfg.milkdrop.presetId, ids[3]); + /* İkinci karışımın presetleri kütüphaneden silindi: ◀ onu atlayıp + birinciye iniyor. Arada durmak (üçüncüde kalmak) yanlış. */ + lib.splice(lib.findIndex((p) => p.id === 'u_a2'), 1); + await new Promise((r) => M.refresh(r)); + GP.mixNav(-1); + assert.strictEqual(cfg.milkdrop.presetId, ids[2]); + GP.mixNav(-1); + assert.strictEqual(cfg.milkdrop.presetId, ids[0], 'silinen parçası olan karışım atlanmalı'); +}); + +test('"yok" olan shader parçası yeniden çekilince bir preset geliyor', async () => { + /* "Yok" şansı yalnız dolu bir parçayı boşaltabiliyor; boş parça yeniden + çekilince mutlaka değişmeli. Math.random sabit: "yok" zarı her seferinde + tutuyor, yani kural gerçekten sınanıyor. */ + const { M, GP, lib } = await fresh(); + M.preview(byId(lib, 'u_b3')); + GP.fromScreen(); + assert.strictEqual(donorsOf(GP).warp, X.NONE); + const orig = Math.random; + Math.random = () => 0.05; + try { + GP.rollSlot('warp'); + GP.rollSlot('comp'); + } finally { + Math.random = orig; + } + const mix = donorsOf(GP); + assert.ok(mix.warp && X.has(byId(lib, mix.warp).source, 'warp'), 'warp: ' + mix.warp); + assert.ok(mix.comp && X.has(byId(lib, mix.comp).source, 'comp'), 'comp: ' + mix.comp); +}); + +test('kaydet son karışımı yazıyor; aynı karışım aynı kimlik', async () => { + const { GP, saved } = await fresh(); + // Üreticinin de bir sonucu var: karışımın düğmesi onu değil karışımı yazmalı + GP.regenerate(); + GP.rollAll(); + const { mixLast } = GP.state(); + await GP.saveMix(); + await GP.saveMix(); + assert.strictEqual(saved.length, 2); + assert.deepStrictEqual(saved[0], { id: mixLast.id, kind: 'milkdrop', name: mixLast.name, source: mixLast.source, author: 'Üretici' }); + assert.strictEqual(saved[1].id, saved[0].id); + assert.match(mixLast.name, /^Karışım · /); +}); + +test('kart: parça satırları, "yok" ve geçmiş düğmeleri', async () => { + const { M, GP, lib } = await fresh(); + const tree = () => GP.panel(); + const btn = (t, re) => findAll(t, (n) => n.tag === 'button' && re.test(String(n.text))).pop(); + let t = tree(); + assert.strictEqual(findAll(t, (n) => n.className === 'mdmix-row').length, 0, 'karışım yokken satır yok'); + assert.ok(btn(t, /^◀$/).attrs.disabled !== undefined || btn(t, /^◀$/).props.disabled === true); + M.preview(byId(lib, 'u_b3')); + GP.fromScreen(); + t = tree(); + const rows = findAll(t, (n) => n.className === 'mdmix-row'); + assert.strictEqual(rows.length, 6); + const names = rows.map((r) => r.kids[1].text); + assert.deepStrictEqual(names, ['Beta 3', 'Beta 3', 'Beta 3', 'Beta 3', 'Yok', 'Yok']); + GP.rollAll(); + t = tree(); + assert.strictEqual(btn(t, /^◀$/).props.disabled, false, 'iki karışımdan sonra geri açık'); + assert.strictEqual(btn(t, /^▶$/).props.disabled, true); + assert.strictEqual(findAll(t, (n) => /Kütüphaneye Kaydet/.test(String(n.text))).length, 2, 'iki bölümün kendi kaydet düğmesi'); +}); From 0e6f99db4338c8e8a6edc822d492782d5c712c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 23 Sep 2026 06:28:33 +0300 Subject: [PATCH 3/4] docs: MilkDrop mash-ups Refs #579 --- README.md | 14 +++++++-- README.tr.md | 15 ++++++++-- ROADMAP.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 92fe997..d6022f6 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-2147%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2166%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -380,6 +380,16 @@ that asserts the bar profile has no step in it. 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. +- **Mash-ups from your library.** The same card builds a preset out of pieces of the presets you + already have: the look (decay, echo, gamma, the main wave), the motion equations, the custom + waves, the custom shapes, the warp shader and the composite shader — each taken whole from one + preset and copied line for line. *New Mash-up* draws every part at random from the presets the + MilkDrop panel's list shows, so its search and filter apply, and only from presets that have that + part. A single part can be drawn again, or all six can start from the preset on screen; ◀ ▶ step + back through earlier mash-ups, and *Save to Library* keeps one. A mash-up inherits its donors' + looks, so it can come out brighter or darker than any of them. Checked on a 10,332-preset corpus: + a mash-up of one preset is that preset again, and in 5,000 random mash-ups every part was + exactly its donor's. 300 random mash-ups ran in the engine without a single shader failure. - **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 @@ -1279,7 +1289,7 @@ npm test npm start -- --smoke ``` -**2147 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2166 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 db5a546..5956714 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-2147%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2166%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -373,6 +373,17 @@ profilinde basamak olmadığını doğruluyor. 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ı. +- **Kütüphaneden karışım.** Aynı kart zaten sahip olduğunuz presetlerin parçalarından bir preset + kuruyor: görünüm (sönme, yankı, gama, ana dalga), hareket denklemleri, özel dalgalar, özel + şekiller, warp shader'ı ve birleştirme shader'ı — her biri bütünüyle tek bir presetten, satır + satır kopyalanarak. *Yeni Karışım* her parçayı MilkDrop panelinin listesinde görünen + presetlerden rastgele çekiyor, yani oradaki arama ve süzgeç burada da geçerli, ve yalnız o + parçası olan presetlerden. Tek bir parça yeniden çekilebiliyor ya da altısı birden ekrandaki + presetten başlayabiliyor; ◀ ▶ önceki karışımlara dönüyor, *Kütüphaneye Kaydet* birini saklıyor. + Karışım parçalarını veren presetlerin görünüşünü taşıyor, yani onlardan daha parlak ya da daha + karanlık çıkabilir. 10.332 presetlik bir korpusta denetlendi: tek presetten kurulan karışım + presetin kendisi, 5.000 rastgele karışımda da her parça onu veren presetinkiyle birebir aynı. + 300 rastgele karışım motorda koşturuldu; hiçbir shader aşaması düşmedi. - **`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 @@ -1276,7 +1287,7 @@ npm test npm start -- --smoke ``` -**2147 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2166 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 bfdcf0a..0cd59e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2147 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2166 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 550 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 569 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1858,8 +1858,8 @@ 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. +- **A preset generator of our own, and mash-ups (#579)** · done. The + generator came first; the mash-ups are described after it. - **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 @@ -1934,8 +1934,63 @@ rest after. No version number yet. 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. + - **Not done:** a MIDI/OSC action and a web-remote button that generate + or mix. + - **Mash-ups.** The same card builds a preset out of six parts of the + presets in the library: the look (decay, gamma, echo, the four flags, + the main wave, borders, motion vectors, the rating), the motion + (`per_frame_init`, `per_frame`, `per_pixel` and zoom, rot, cx, cy, dx, + dy, warp, sx, sy, `fWarpAnimSpeed`, `fWarpScale`, `fZoomExponent`), the + custom waves, the custom shapes, the warp shader, and the composite + shader with the blur ranges (`b1n`…`b3x`, `b1ed`) — `GetBlur`'s scale + comes from them and the composite shader reads the blur most. Every + key belongs to exactly one part; the list was counted from the corpus + (86 header keys), and an unknown key goes to the look. A part comes + whole from one preset and its lines are copied as they are, numbers + included. The version lines are written anew from the presets that give + the shaders, since MilkDrop 2 reads a shader only when its version is + above zero. Written from a description of the behaviour in our own + words; MilkDrop 2's mash-up code was not opened. + - *New Mash-up* draws each part from the presets the MilkDrop panel's + list shows — its search and filter apply — and only from presets that + have the part. Warp and composite come out "none" 12% of the time: + in a library of mostly MilkDrop 2 presets every mash-up would + otherwise get both shaders, and the look's fixed pipeline (echo, + gamma, flags) would never run. A single part can be drawn again, or + all six can start from the preset on screen; an unsaved preview (a + generated preset or a mash-up) is never in the list, so it never + gives a part. ◀ ▶ step through a history of recipes (part → preset); + a recipe whose preset was deleted is skipped. The id comes from the + recipe (`md_mix1_` and two 32-bit hashes), so the same mash-up saved + twice leaves one file, and the version in it keeps later rule changes + from overwriting earlier saves. + - *Part tests* decide which presets can give a part without parsing + them (13.5 µs a test): a non-comment equation line, an enabled wave + or shape block, a non-empty shader line. + - **Mash-ups measured** on the whole 10,332-preset corpus. The part tests + agree with the parser for every preset and part. A mash-up whose six + parts come from one preset is that preset again for all 10,332 — + equations, waves, shapes, shaders and every parameter; only 5 files + get different version lines, all malformed or old headers (three with + `MILKDROP_PRESET_VERSION` but no `PSVERSION`, one declaring a composite + shader that is empty, one version-200 file). In 5,000 random mash-ups + (warp and composite "none" one time in ten) every part equals its + donor's, parameters split by part, and the version lines match the + shaders. 300 random mash-ups ran in the engine (214 with a warp shader, + 221 with a composite one): no shader stage or equation failed that did + not fail in its own preset, and there were none of those either. In an + isolated copy with 40 corpus presets: a search in the MilkDrop panel + left 20 in the list and six mash-ups drew 14 different presets, all + from those 20; the visualizer window drew the mash-up; drawing one part + again changed only that part; ◀ went back; saving twice left one file; + the English UI had no Turkish left. + - **Found by the mash-up measurement.** The composite part's blur ranges + were first taken for a shader, and 308 MilkDrop 1 presets that have + them got version lines for a composite shader they do not have; only + `comp_N` lines count now. One corpus file writes the same key twice + (`shapecode_2_enabled=1`, later `=0`): our parser keeps the last value + and the part test follows it. Which one MilkDrop 2 keeps was not + checked here (#580). - **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 @@ -1947,7 +2002,21 @@ rest after. No version number yet. 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. + the sliders, 🎲 and the wiring. 26 of 26 mutations are caught. The + mash-ups add 19: 10 for the module — every key's part, one-preset round + trip, mixed presets part by part, lines copied as they are, version + lines (MilkDrop 1 look with MilkDrop 2 shaders, none, composite only, + version 3 kept, a shaderless MilkDrop 2 file kept), "none" taking the + blur ranges with it, the part tests against the parser on edge cases + from the corpus, id and name, and candidate picking — and 9 for the + panel with a fake DOM, the search typed into the MilkDrop panel's real + search box: parts only from the visible list and only from presets + with the part, the mash-up's text from its donors, one part drawn again + leaving the others, starting from the preset on screen (and never from + an unsaved preview), a "none" part drawn again always giving a preset, + the history skipping a deleted preset, saving the mash-up and not the + generated preset, and the rows. 23 of 23 mutations are caught; a 24th + was equivalent, and the branch it changed was removed. ## v3.1.6 — Comprehensive video export From 77847aa0602773b7dd099bc4cc9417f85b7528b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Wed, 23 Sep 2026 06:36:35 +0300 Subject: [PATCH 4/4] fix(milkdrop): generated and mash-up names stay names in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface splits text at " · " and translates the pieces: "Karismim · ..." (layer blend) read "Blend · ..." and the generated pair "Dingin Halkalar" is a builtin preset's name ("Still Rings"). Mash-up names use ": " and the ring nouns are now Cemberler/Circles. A test runs every generated word pair and a mash-up name through the English translation; the card's wiring test requires the mash-up module. Refs #579 --- README.md | 4 ++-- README.tr.md | 4 ++-- ROADMAP.md | 21 +++++++++++++---- src/shared/milkdrop-generator.js | 14 +++++++---- src/shared/milkdrop-mashup.js | 9 ++++++-- tests/i18n.test.js | 36 +++++++++++++++++++++++++++++ tests/milkdrop-gen-panel.test.js | 4 ++++ tests/milkdrop-mashup-panel.test.js | 2 +- tests/milkdrop-mashup.test.js | 4 ++-- 9 files changed, 80 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d6022f6..5f099a4 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-2166%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2167%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1289,7 +1289,7 @@ npm test npm start -- --smoke ``` -**2166 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2167 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 5956714..d8aad28 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-2166%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2167%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1287,7 +1287,7 @@ npm test npm start -- --smoke ``` -**2166 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2167 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 0cd59e7..91169ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2166 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2167 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 569 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 570 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1990,7 +1990,14 @@ rest after. No version number yet. `comp_N` lines count now. One corpus file writes the same key twice (`shapecode_2_enabled=1`, later `=0`): our parser keeps the last value and the part test follows it. Which one MilkDrop 2 keeps was not - checked here (#580). + checked here (#580). A check of the English interface found two names + that did not stay names: the interface splits text at " · " and + translates the pieces, so a mash-up saved as "Karışım · …" read + "Blend · …", and one generated word pair, "Dingin Halkalar" ("Still + Rings"), is a builtin preset's name. Mash-up names now use ": ", the + ring nouns are "Çemberler" and "Circles", and a test runs every + generated word pair and a mash-up name through the English + translation. - **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 @@ -2015,8 +2022,12 @@ rest after. No version number yet. leaving the others, starting from the preset on screen (and never from an unsaved preview), a "none" part drawn again always giving a preset, the history skipping a deleted preset, saving the mash-up and not the - generated preset, and the rows. 23 of 23 mutations are caught; a 24th - was equivalent, and the branch it changed was removed. + generated preset, and the rows. One more checks the names against the + English translation, and the card's wiring test now also requires the + mash-up module before the panel. 23 of 23 mutations are caught; a 24th + was equivalent, and the branch it changed was removed. Putting back + the old ring nouns, the " · " in mash-up names or removing the + module's script tag each fails a test. ## v3.1.6 — Comprehensive video export diff --git a/src/shared/milkdrop-generator.js b/src/shared/milkdrop-generator.js index c501939..db5c9b8 100644 --- a/src/shared/milkdrop-generator.js +++ b/src/shared/milkdrop-generator.js @@ -778,14 +778,20 @@ // -------------------------------------------------------------------- /* 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. */ + arayüzde üretilen preset İngilizce adla kaydediliyor. + + Sözcük çiftlerinden hiçbiri sözlükte anahtar ya da bir yerleşik + presetin adı olmamalı: çeviri adı " · "den bölüp ilk parçayı çeviriyor. + "Dingin Halkalar" yerleşik bir presetin adıydı ve İngilizce arayüzde + "Still Rings" okunuyordu; halka sözcükleri bu yüzden "Çemberler" ve + "Circles". Test bütün çiftleri sözlüğe karşı sınıyor. */ 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ı'], + tunel: ['Tünel', 'Geçit'], girdap: ['Girdap', 'Anafor'], halka: ['Çemberler', 'Yankı'], akinti: ['Akıntı', 'Rüzgâr'], cicek: ['Çiçek', 'Taç'], nefes: ['Nefes', 'Nabız'], }, }, @@ -794,7 +800,7 @@ ['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'], + tunel: ['Tunnel', 'Passage'], girdap: ['Vortex', 'Whirl'], halka: ['Circles', 'Echo'], akinti: ['Current', 'Drift'], cicek: ['Bloom', 'Crown'], nefes: ['Breath', 'Pulse'], }, }, @@ -1072,7 +1078,7 @@ /* 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 }, + _test: { num, sway, withAudio, NAME_WORDS }, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof window !== 'undefined') window.SVMdGen = api; diff --git a/src/shared/milkdrop-mashup.js b/src/shared/milkdrop-mashup.js index f5122ae..4445e17 100644 --- a/src/shared/milkdrop-mashup.js +++ b/src/shared/milkdrop-mashup.js @@ -207,7 +207,12 @@ /* Ad: kelime ve parçaları veren presetlerin adları (ilk üç ayrı ad, kısaltılmış). Arayüz dilinde; kaynağa girmiyor. `nameOf(id)` presetin - görünen adını veriyor (yerleşiklerin adı çevrilebiliyor). */ + görünen adını veriyor (yerleşiklerin adı çevrilebiliyor). + + Ayırıcı ": ", " · " değil: çeviri " · " gördüğü metni bölüp parçaları + ayrı ayrı çeviriyor ve "Karışım" sözlükte ("Blend", katman karışımı). + Türkçe arayüzde kaydedilmiş bir karışım İngilizce arayüzde "Blend · …" + okunurdu. Ad, presetin kendi adı olarak bütün kalıyor. */ function nameFor(recipe, nameOf, lang) { const seen = new Set(); const names = []; @@ -219,7 +224,7 @@ if (n) names.push(n); if (names.length === 3) break; } - return (WORD[lang === 'en' ? 'en' : 'tr']) + ' · ' + names.join(' × '); + return (WORD[lang === 'en' ? 'en' : 'tr']) + ': ' + names.join(' × '); } /* Parçası olan rastgele bir aday. Liste on binlerce preset olabilir: diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 8e38e70..180386f 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -247,3 +247,39 @@ test('canlı parça başlığı i18n taramasının dışında', () => { // Sınıf gerçekten canlı başlığa veriliyor mu assert.match(admin, /class: 'np-live'[^}]*\$\{live\.title\}/); }); + +/* ÜRETİLEN VE KARIŞIM PRESETLERİNİN ADI (#579) çeviriden olduğu gibi + geçmeli: bir presetin adı onun kendi adı. Çeviri " · " gördüğü metni + bölüp parçaları ayrı ayrı çeviriyor; adın bir parçası sözlükte anahtar + olunca İngilizce arayüzde başka bir şey okunuyor. İki kez böyle oldu: + "Karışım · …" → "Blend · …" (katman karışımı), ve üretilen + "Dingin Halkalar" yerleşik bir presetin adıydı → "Still Rings". */ +test('üretilen ve karışım adları çeviriden olduğu gibi geçiyor', () => { + const t = loadEnglish().t; + const G = require('../src/shared/milkdrop-generator.js'); + const X = require('../src/shared/milkdrop-mashup.js'); + const B = require('../src/shared/presets-milkdrop.js'); + const W = G._test.NAME_WORDS; + const builtin = new Set(B.map((p) => p.name).concat(B.map((p) => t(p.name)))); + let pairs = 0; + for (const lang of ['tr', 'en']) { + const adjs = [].concat(...W[lang].tone, ...W[lang].mood); + for (const a of adjs) { + for (const nouns of Object.values(W[lang].noun)) { + for (const n of nouns) { + const name = a + ' ' + n + ' · k3x9ab'; + assert.strictEqual(t(name), name, lang + ': ' + name); + assert.ok(!builtin.has(a + ' ' + n), 'yerleşik presetin adı: ' + a + ' ' + n); + pairs++; + } + } + } + } + assert.ok(pairs > 500, 'çift sayısı ' + pairs); + // Karışım adı, parçaları veren presetlerin adları sözlükte olsa da bütün kalıyor + for (const lang of ['tr', 'en']) { + const n = X.nameFor({ look: 'a', motion: 'b', waves: 'a', shapes: 'a', warp: '', comp: '' }, + (id) => ({ a: 'Tünel', b: 'Girdap' })[id], lang); + assert.strictEqual(t(n), n, n); + } +}); diff --git a/tests/milkdrop-gen-panel.test.js b/tests/milkdrop-gen-panel.test.js index a759fc8..7fe1a98 100644 --- a/tests/milkdrop-gen-panel.test.js +++ b/tests/milkdrop-gen-panel.test.js @@ -251,4 +251,8 @@ test('kart bağlı: bölüm, denetim, betik sırası ve çeviriler', () => { 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"')); + /* Karışım bölümü yalnız SVMdMix varsa çiziliyor: betik düşerse bölüm + sessizce kaybolur, ne bir test ne duman testi düşerdi (#579). */ + assert.ok(at('../shared/milkdrop-mashup.js') > 0, 'karışım modülü yüklenmiyor'); + assert.ok(at('../shared/milkdrop-mashup.js') < at('milkdrop-gen.js"'), 'karışım modülü panelden sonra'); }); diff --git a/tests/milkdrop-mashup-panel.test.js b/tests/milkdrop-mashup-panel.test.js index 7f737d0..725f401 100644 --- a/tests/milkdrop-mashup-panel.test.js +++ b/tests/milkdrop-mashup-panel.test.js @@ -265,7 +265,7 @@ test('kaydet son karışımı yazıyor; aynı karışım aynı kimlik', async () assert.strictEqual(saved.length, 2); assert.deepStrictEqual(saved[0], { id: mixLast.id, kind: 'milkdrop', name: mixLast.name, source: mixLast.source, author: 'Üretici' }); assert.strictEqual(saved[1].id, saved[0].id); - assert.match(mixLast.name, /^Karışım · /); + assert.match(mixLast.name, /^Karışım: /); }); test('kart: parça satırları, "yok" ve geçmiş düğmeleri', async () => { diff --git a/tests/milkdrop-mashup.test.js b/tests/milkdrop-mashup.test.js index c3d1934..7290988 100644 --- a/tests/milkdrop-mashup.test.js +++ b/tests/milkdrop-mashup.test.js @@ -220,8 +220,8 @@ test('kimlik tariften: aynı tarif aynı kimlik, depo adıyla uyumlu', () => { test('ad: kelime ve ilk üç ayrı preset, uzunlar kısaltılmış', () => { const names = { a: 'Alfa', b: 'Beta', c: 'Gamma Delta Epsilon Zeta Eta Theta', d: 'Delta' }; const r = { look: 'a', motion: 'b', waves: 'a', shapes: 'c', warp: '', comp: 'd' }; - assert.strictEqual(X.nameFor(r, (id) => names[id], 'tr'), 'Karışım · Alfa × Beta × Gamma Delta Epsilon Z…'); - assert.strictEqual(X.nameFor(r, (id) => names[id], 'en'), 'Mash-up · Alfa × Beta × Gamma Delta Epsilon Z…'); + assert.strictEqual(X.nameFor(r, (id) => names[id], 'tr'), 'Karışım: Alfa × Beta × Gamma Delta Epsilon Z…'); + assert.strictEqual(X.nameFor(r, (id) => names[id], 'en'), 'Mash-up: Alfa × Beta × Gamma Delta Epsilon Z…'); }); // ------------------------------------------------------------------- aday