diff --git a/README.md b/README.md index d49ff72..f697411 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-2577%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2613%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1109,6 +1109,23 @@ crosses, colour bars and focus rings · drag, arrow-key nudge and exact numeric visualizer layer its own mode. A status line says what changed, what is next, and why nothing can happen when a source is empty. +### MCP + +An agent can drive the app from the **Control** card. The card is off by default. Five modes +stack, and the one that turns on with the switch is **Read**: + +- **Read** sees the show and changes nothing. **Apply** can use scenes, effects and presets that + already exist. **Write** can create and edit, including Auto VJ. **Full** adds the live + surfaces: lighting, mapping, windows, timeline transport, Spout and the stream. **Everything** + also covers updates, audio repair and stream-token rotation. +- The server listens on this computer only, `127.0.0.1`, port **38471** unless you confirm + another. A busy port is not replaced. The agent connects with the stdio command from the setup + dialog; that bridge talks to `http://127.0.0.1:/mcp`. +- Tools cover scenes, layers, effects, presets, displays, the floating window, the clip deck, + timeline transport, recording and export. A blocked call names the mode that is required and + tells the agent not to change the mode and not to click the panel. Changes the agent is allowed + to make show up in the panel the same way a click does, including which displays are selected. + ### Windows Dynamic Lighting - Off by default, and available only when compatible devices are detected. @@ -1341,7 +1358,7 @@ npm test npm start -- --smoke ``` -**2577 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2613 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 7b4203f..018d634 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-2577%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2613%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1109,6 +1109,23 @@ sayısal giriş. mod verebilirsiniz. Durum satırı ne değiştiğini, sıradakini ve kaynak boşsa neden hiçbir şey olamayacağını yazar. +### MCP + +Bir ajan uygulamayı **Kontrol** kartından sürebilir. Kart varsayılan olarak kapalıdır. Beş kip +birikir; anahtar açılınca gelen kip **Okuma**dır: + +- **Okuma** gösteriyi görür, hiçbir şeyi değiştirmez. **Uygula** zaten duran sahne, efekt ve + presetleri kullanabilir. **Yazma** oluşturur ve düzenler, Otomatik VJ dahil. **Tam** canlı + yüzeyleri ekler: ışık, eşleme, pencereler, zaman çizelgesi, Spout ve yayın. **Her şey** buna + güncellemeyi, ses onarımını ve yayın jetonu döndürmeyi de katar. +- Sunucu yalnız bu bilgisayarda dinler, `127.0.0.1`, port **38471** — siz başka bir port + onaylamadıkça. Port meşgulse kendiliğinden değişmez. Ajan, kurulum penceresindeki stdio + komutuyla bağlanır; bu köprü `http://127.0.0.1:/mcp` adresine konuşur. +- Araçlar sahne, katman, efekt, preset, ekran, yüzen pencere, klip destesi, zaman çizelgesi, + kayıt ve dışa aktarmayı kapsar. Engellenen çağrı gereken kipin adını söyler ve ajana kipi + değiştirmemesini, panele tıklamamasını bildirir. İzin verilen değişiklikler panelde tıklamayla + aynı yoldan görünür; seçili ekranlar dahil. + ### Windows Dynamic Lighting - Varsayılan olarak kapalıdır ve yalnızca uyumlu aygıtlar algılandığında görünür. @@ -1341,7 +1358,7 @@ npm test npm start -- --smoke ``` -**2577 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2613 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 a1b8d02..bdc972d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ npm test npm start -- --smoke ``` -- **2577 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2613 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 878 on `main` since. @@ -1738,7 +1738,9 @@ rest after. No version number yet. - **Store.** Files are read once — in the background at start-up — and kept; saves and deletes update the cache. `list()` still looks at the folder's file names on every call, so a file added or removed by hand - shows up; a file edited by hand shows up after a restart. Bulk saves are + shows up. A file edited by hand is re-read when the folder watcher or an + MCP turn names it, and open windows take that change without a restart. + Bulk saves are written in batches with pauses so the main process keeps serving audio, lights and IPC, report progress, and keep the pack's own order. - **Change broadcast.** A save or delete sends only what changed @@ -3068,6 +3070,25 @@ expects. The light each preset leaves now matches MilkDrop's to three decimals. - **Not matched:** MilkDrop's thin line lands between two rows at about half brightness, where ours lights one row fully. The light is the same; the half-texel placement is Direct3D 9's rasterisation and is not chased. +## Next, not yet numbered — MCP + +Local only, not in a numbered release. The Control card lets an agent drive +the app. It is off by default. + +- Five modes stack: Read, Apply, Write, Full, Everything. Turning the card on + starts at Read. Read changes nothing. Apply uses what already exists. Write + creates and edits, including Auto VJ. Full adds lighting, mapping, windows, + timeline transport, Spout and the stream. Everything adds updates, audio + repair and stream-token rotation. +- The server binds `127.0.0.1` only, port 38471 unless the user confirms + another. A busy port is left busy. Agents use the stdio command from the + setup dialog, which forwards to `http://127.0.0.1:/mcp`. +- Tools cover scenes, layers, effects, presets, displays, the floating window, + the clip deck, timeline transport, recording and export. A denied call names + the minimum mode and tells the agent not to change the mode or click the + panel. Allowed edits refresh the admin panel through the same config push a + click already uses, including the display selection. + ## v3.1.6 — Comprehensive video export Faster export, more formats, and enough presets that a content creator never diff --git a/src/admin/admin.css b/src/admin/admin.css index b7da4c6..c070239 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -602,18 +602,23 @@ select:focus { border-color: var(--accent); } position: absolute; top: 4px; right: 4px; - width: 18px; - height: 18px; + z-index: 3; + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; border: 0; border-radius: 50%; - background: rgba(0,0,0,0.65); + background: rgba(12, 8, 10, 0.88); color: #fff; - font-size: 10px; cursor: pointer; - line-height: 18px; padding: 0; + line-height: 0; } -.logo-lib-del:hover { background: rgba(230,60,60,0.95); } +.logo-lib-del .svi { width: 12px; height: 12px; color: #fff; } +.logo-lib-del:hover, +.logo-lib-del:focus-visible { background: #e23b3b; color: #fff; } .subctrls { border-left: 2px solid var(--line2); padding-left: 12px; margin-top: 6px; } @@ -1320,3 +1325,118 @@ input[type=color].tl-swatch.big { width: 34px; height: 24px; } .upd-status.available { color: var(--ok); font-weight: 600; } .upd-status.error { color: var(--warn); } .upd-notes { white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow: auto; margin: 6px 0 0; padding: 8px 10px; font: 12px/1.5 ui-monospace, Consolas, monospace; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; color: var(--text); } + + +/* MCP control card (under Kontrol). Same red/dark cards, switches, and notes. */ +.mcp-card { display: grid; gap: 8px; } +.mcp-status { + padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; + background: var(--card2); color: var(--muted); font-size: 11.5px; +} +.mcp-group { border-top: 1px solid var(--line); padding-top: 8px; } +.mcp-group-title { font-size: 12px; font-weight: 700; margin-bottom: 2px; } +.mcp-switches { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; } +.mcp-card .switch input:disabled + .track { opacity: 0.38; filter: grayscale(0.35); } +.mcp-setup { + width: min(720px, calc(100vw - 28px)); + max-height: min(82vh, 780px); + overflow: auto; +} +.mcp-setup-note { font-size: 12px; color: var(--muted); line-height: 1.5; margin-bottom: 8px; } +.mcp-client { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); } +.mcp-client h4 { margin: 0 0 6px; font-size: 13px; } +.mcp-steps { margin: 0 0 8px 18px; color: var(--muted); font-size: 12px; line-height: 1.45; } +.mcp-snippet { + margin: 0 0 8px; padding: 10px; border-radius: 8px; + border: 1px solid var(--line); background: #12080b; color: #f3d0d4; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; line-height: 1.45; white-space: pre-wrap; word-break: break-all; +} + +.mcp-modes { display: grid; gap: 6px; } +.mcp-mode { + text-align: left; border: 1px solid var(--line); background: var(--card2); + color: inherit; border-radius: 9px; padding: 8px 10px; cursor: pointer; +} +.mcp-mode.on { border-color: #c4364a; } +.mcp-mode-name { display: block; font-weight: 700; font-size: 12px; } +.mcp-mode-hint { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; } + +.mcp-setup-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +.mcp-setup-head .ask-title { margin: 0; } +.mcp-setup-x { min-width: 32px; } +.mcp-port { display: flex; align-items: center; gap: 8px; } +.mcp-port input { width: 96px; } +.mcp-port .btn { + height: 30px; + box-sizing: border-box; + display: inline-flex; + align-items: center; +} + +/* Metin ve sayı kutuları. Sınıfsız type=number (MCP portu dahil) Windows'ta + açık dolgu ve oklarla kalıyordu. Boy, .btn.small ile aynı 30px. */ +input[type="text"]:not(.scene-name), +input[type="search"], +input[type="url"], +input[type="password"], +input[type="email"], +input[type="tel"], +input[type="number"], +textarea:not(.code-input):not(.mded-input) { + background: var(--card2); + color: var(--text); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 8px; + font: inherit; + font-size: 11.5px; + line-height: 1.2; + outline: none; + height: 30px; + box-sizing: border-box; +} +textarea:not(.code-input):not(.mded-input) { + height: auto; + min-height: 30px; + padding: 6px 8px; +} +input[type="text"]:not(.scene-name):focus, +input[type="search"]:focus, +input[type="url"]:focus, +input[type="password"]:focus, +input[type="email"]:focus, +input[type="tel"]:focus, +input[type="number"]:focus, +textarea:not(.code-input):not(.mded-input):focus { + border-color: var(--accent); +} +input[type="number"] { + -moz-appearance: textfield; + appearance: textfield; +} +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + display: none; +} +.topsearch input[type="text"] { + height: auto; + background: none; + border: none; + border-radius: 0; + padding: 0; +} +.mcp-prompt-block { display: grid; gap: 8px; } +.mcp-manual { + margin-top: 26px; + padding-top: 18px; + border-top: 1px solid var(--line2); +} +.mcp-manual-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 800; + letter-spacing: .01em; +} diff --git a/src/admin/admin.js b/src/admin/admin.js index b273f63..a75aa72 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -486,6 +486,8 @@ return window.SVStudio ? window.SVStudio.panel() : null; case 'controlpanel': return window.SVControl ? window.SVControl.panel(def.surface) : null; + case 'mcppanel': + return window.SVMcpPanel ? window.SVMcpPanel.panel() : null; case 'mediapanel': return window.SVMediaPanel ? window.SVMediaPanel.panel() : null; case 'scenegen': @@ -1240,6 +1242,13 @@ blob önbelleğinden geldiği için yeniden mount donmaya yol açmaz. */ render(); }, + onRemove: (it) => { + if (!cfg.logo || cfg.logo.libraryId !== it.id) return; + cfg.logo.libraryId = ''; + if (!cfg.logo.src) cfg.logo.kind = ''; + push(true); + render(); + }, }); } @@ -2826,6 +2835,15 @@ desc: 'TouchOSC, Resolume, Ableton veya QLab gibi kaynaklardan gelen OSC mesajlarını ayarlara bağlayın.', controls: [{ type: 'controlpanel', surface: 'osc' }], }, + { + id: 'mcp', + category: 'control', + icon: 'sliders', + wide: true, + title: 'MCP', + desc: 'Ajan bu karttaki kiple sürer. Kapalı başlar; açılınca okuma. Her şey kipi tek tık ve varsayılan değil.', + controls: [{ type: 'mcppanel' }], + }, { id: 'studio', category: 'studio', @@ -3815,13 +3833,9 @@ ilk ekranı yansıtmayı sürdürür. */ function syncSelectedDisplays() { cfg.display = cfg.display || {}; - // Artık bağlı olmayan ekranları ayıkla + // Artık bağlı olmayan ekranları ayıkla. Boş liste boş kalır: + // kullanıcı bütün kutuları kaldırdığında bir ekran geri seçilmez. selectedDisplayIds = selectedDisplayIds.filter((id) => displays.some((d) => d.id === id)); - if (!selectedDisplayIds.length && displays.length) { - // Varsayılan: harici ekran varsa o, yoksa birincil - const ext = displays.find((d) => !d.isPrimary); - selectedDisplayIds = [(ext || displays[0]).id]; - } cfg.display.ids = selectedDisplayIds.slice(); cfg.display.id = selectedDisplayIds[0] != null ? selectedDisplayIds[0] : null; } @@ -4962,29 +4976,132 @@ // Studio presetleri (kullanıcının kendi shader/varyasyon tasarımları). // render() bunlara bakacağı için ÇİZİMDEN ÖNCE yüklenmeli. + /* Liste isteği sürerken gelen kayıtlar kaybolmasın. Dinleyici + cevaptan sonra kurulursa yayın düşer, setUser eski listeyi yazar + ve preset yeniden başlatılana kadar arayüzde görünmez. */ + let presetsReady = false; + const earlyPresetDeltas = []; + let presetGen = 0; + let presetCatchBusy = false; + const onPresetDelta = (d) => { + if (!presetsReady) { earlyPresetDeltas.push(d); return; } + if (d && d.gen) presetGen = Math.max(presetGen, Number(d.gen) || 0); + const S = window.SVPresets; + const ups = (d && Array.isArray(d.upsert)) ? d.upsert : []; + const studio = ups.some((p) => p && p.kind !== 'milkdrop') || + (d && Array.isArray(d.remove) ? d.remove : []).some((id) => { + const p = S && S.get ? S.get(id) : null; + return !!p && p.kind !== 'milkdrop'; + }); + /* Seçili MilkDrop kaynağı dosyada değiştiyse panelin kopyası da + değişsin. Yayın bunu ayara yazıyor; burada da tutmak, aradaki + kaydırıcının eski metni geri göndermesini keser. */ + if (cfg && cfg.milkdrop) { + for (let i = 0; i < ups.length; i++) { + const p = ups[i]; + if (p && p.kind === 'milkdrop' && p.id === cfg.milkdrop.presetId && typeof p.source === 'string' && p.source !== cfg.milkdrop.source) { + cfg.milkdrop = Object.assign({}, cfg.milkdrop, { source: p.source, name: p.name || cfg.milkdrop.name }); + break; + } + } + } + if (S && S.applyDelta) S.applyDelta(d); + render(); + if (studio && window.SVPreview && window.SVPreview.notePresets) window.SVPreview.notePresets(); + else if (window.SVPreview) window.SVPreview.setConfig(cfg); + }; + async function catchPresets() { + if (!window.api.presetsSince || presetCatchBusy) return; + presetCatchBusy = true; + try { + const page = await window.api.presetsSince(presetGen); + if (!page) return; + const nextGen = Number(page.gen) || 0; + if (page.reset || page.bulk) { + presetGen = nextGen; + window.SVPresets.setUser(await window.api.listPresets()); + render(); + if (window.SVPreview && window.SVPreview.notePresets) window.SVPreview.notePresets(); + else if (window.SVPreview) window.SVPreview.setConfig(cfg); + return; + } + presetGen = nextGen; + const up = page.upsert || []; + const rm = page.remove || []; + if (!up.length && !rm.length) return; + onPresetDelta({ upsert: up, remove: rm, gen: nextGen }); + } catch { /* ana süreç kapandıysa bir sonraki tur dener */ } + finally { presetCatchBusy = false; } + } + if (window.api.onPresetsDelta) window.api.onPresetsDelta(onPresetDelta); + let headGen = 0; + try { + if (window.api.presetsHead) { + const head = await window.api.presetsHead(); + headGen = head && Number(head.gen) || 0; + } + } catch { headGen = 0; } try { window.SVPresets.setUser(await window.api.listPresets()); } catch { /* preset yoksa yerleşiklerle devam */ } + presetsReady = true; + let maxEarly = 0; + for (let i = 0; i < earlyPresetDeltas.length; i++) { + const d = earlyPresetDeltas[i]; + if (d && d.gen) maxEarly = Math.max(maxEarly, Number(d.gen) || 0); + onPresetDelta(d); + } + try { + if (window.api.presetsSince) { + const page = await window.api.presetsSince(headGen); + if (page) { + presetGen = Math.max(maxEarly, Number(page.gen) || 0); + if (page.reset || page.bulk) { + try { window.SVPresets.setUser(await window.api.listPresets()); } catch { /* liste yok */ } + render(); + if (window.SVPreview && window.SVPreview.notePresets) window.SVPreview.notePresets(); + } else if ((page.upsert && page.upsert.length) || (page.remove && page.remove.length)) { + onPresetDelta({ upsert: page.upsert || [], remove: page.remove || [], gen: page.gen }); + } + } + } + } catch { /* yakalama yoksa yayın yeter */ } + presetGen = Math.max(presetGen, maxEarly); + setInterval(() => { + if (cfg && cfg.mcp && cfg.mcp.enabled) catchPresets(); + }, 400); + window.addEventListener('focus', () => { catchPresets(); }); window.api.onPresets((list) => { window.SVPresets.setUser(list); render(); - if (window.SVPreview) window.SVPreview.setConfig(cfg); + if (window.SVPreview && window.SVPreview.notePresets) window.SVPreview.notePresets(); + else if (window.SVPreview) window.SVPreview.setConfig(cfg); }); - // Değişiklik yayını (#574): bütün liste yalnız açılışta geliyor - if (window.api.onPresetsDelta) { - window.api.onPresetsDelta((d) => { - window.SVPresets.applyDelta(d); - render(); - if (window.SVPreview) window.SVPreview.setConfig(cfg); - }); - } // Uzaktan kumandadan (telefon / OBS sayfası) gelen değişiklik: panelin // kendi kopyası tazelenir ve geri gönderilmez — yoksa sonsuz döngü olur. window.api.onExternalConfig((incoming) => { cfg = window.SV.deepMerge(window.SV.defaultConfig(), incoming); + /* MCP ve telefon aynı yapılandırmayı yollar. Ekran seçimi ayrı bir + değişkende duruyordu; render() cfg'yi çizse de menü eski kutuyu + işaretli bırakıyordu. Tıklamadaki gibi seçimi ve sahne vurgusunu + gelen duruma çek, sonra aynı çizimi çalıştır. */ + if (cfg.display && displays.length) { + const ids = Array.isArray(cfg.display.ids) && cfg.display.ids.length + ? cfg.display.ids + : (cfg.display.id != null ? [cfg.display.id] : []); + selectedDisplayIds = ids.map(Number); + renderDisplays(); + } + if (Object.prototype.hasOwnProperty.call(cfg, '_activeSceneId')) { + activeSceneId = cfg._activeSceneId || null; + } + const blackBtn = $('blackoutBtn'); + if (blackBtn) blackBtn.classList.toggle('on', !!(cfg && cfg.isBlackout) || isBlackedOut()); render(); renderScenes(); + /* render() önizlemeyi katman paneli cfg'yi düzeltmeden önce kuruyor. + Panel açılıp kapanmadan çıkış ve önizleme aynı cfg'yi görsün. */ if (window.SVPreview) window.SVPreview.setConfig(cfg); }); diff --git a/src/admin/autovj.js b/src/admin/autovj.js index 3fd026d..197ed74 100644 --- a/src/admin/autovj.js +++ b/src/admin/autovj.js @@ -275,6 +275,7 @@ if (el) el.textContent = tempo.bpm ? Math.round(tempo.bpm) + ' BPM' : '— BPM'; const conf = document.getElementById('bpmConf'); if (conf) conf.style.width = Math.round(tempo.confidence * 100) + '%'; + if (window.api && window.api.mcpReportLive) window.api.mcpReportLive({ bpm: tempo.bpm || 0, confidence: tempo.confidence || 0 }); const dot = document.getElementById('beatDot'); if (dot) dot.classList.toggle('hit', tempo.energy > 0.35); /* Durum satırı yerinde güncelleniyor — paneli yeniden çizmeden. diff --git a/src/admin/index.html b/src/admin/index.html index ea18483..ea4c65a 100644 --- a/src/admin/index.html +++ b/src/admin/index.html @@ -321,6 +321,8 @@

Sahneler

+ + diff --git a/src/admin/logo-lib-ui.js b/src/admin/logo-lib-ui.js index 48ee2a0..8cbc885 100644 --- a/src/admin/logo-lib-ui.js +++ b/src/admin/logo-lib-ui.js @@ -10,6 +10,7 @@ const v = attrs[k]; if (v == null || v === false) return; if (k === 'class') n.className = v; + else if (k === 'icon') { /* SVG aşağıda; öznitelik olarak kalırsa düğme boş ve renksiz kalır */ } else if (k === 'text') n.textContent = v; else if (k === 'html') n.innerHTML = v; else if (k.indexOf('on') === 0 && typeof v === 'function') n.addEventListener(k.slice(2).toLowerCase(), v); @@ -17,6 +18,13 @@ else n.setAttribute(k, v === true ? '' : v); }); } + /* admin el() ile aynı: ikon currentColor çizgisi. icon="x" özniteliği + SVG üretmiyordu; sil düğmesi koyu boş daire olarak kalıyordu. */ + if (attrs && attrs.icon && window.SVIcons) { + const txt = n.textContent; + n.insertBefore(window.SVIcons.el(attrs.icon, txt ? 'svi-lead' : ''), n.firstChild); + n.setAttribute('data-icon', attrs.icon); + } (kids || []).forEach((c) => { if (c) n.appendChild(c); }); return n; } @@ -123,13 +131,13 @@ type: 'button', icon: 'x', title: tr('Sil'), + 'aria-label': tr('Sil'), }); const card = el('button', { class: 'logo-lib-card' + (it.id === selectedId ? ' is-on' : ''), type: 'button', }, [thumb, badge, name].filter(Boolean)); - card.addEventListener('click', (e) => { - if (e.target === del) return; + card.addEventListener('click', () => { selectedId = it.id; paint(); if (onPick) onPick(it); @@ -138,7 +146,16 @@ e.preventDefault(); e.stopPropagation(); if (!window.api || !window.api.logoLibRemove) return; - await window.api.logoLibRemove(it.id); + let removed = false; + try { + const r = await window.api.logoLibRemove(it.id); + removed = !r || r.ok !== false; + } catch { removed = false; } + if (!removed) return; + if (selectedId === it.id) selectedId = ''; + /* Seçili kart ile ekrandaki logo ayrı durabilir. Silinen kimlik + sahnedeyse onu da bırak; seçili değil diye ölü kimlik kalmasın. */ + if (opts && opts.onRemove) opts.onRemove(it); await refresh(); }); const cell = el('div', { class: 'logo-lib-cell' }, [card, del]); diff --git a/src/admin/mcp-panel.js b/src/admin/mcp-panel.js new file mode 100644 index 0000000..87533e2 --- /dev/null +++ b/src/admin/mcp-panel.js @@ -0,0 +1,233 @@ +'use strict'; +/* MCP card under Control. Master switch stays collapsed until it is on. + Modes render as soon as the switch is on. Listener status is a separate line. */ +(function () { + const P = function () { return window.SVPanel; }; + function tr(s) { + return (window.SVI18n && window.SVI18n.t ? window.SVI18n.t(s) : s) || s; + } + function tx(key) { + const loc = window.SVI18n && window.SVI18n.locale; + if (window.SVI18n && window.SVI18n.mcpText) return window.SVI18n.mcpText(key, loc); + return tr(key); + } + let status = { running: false, port: 0, shell: '', script: '', phase: 'off' }; + let loaded = false; + let polls = 0; + + function refresh() { + if (!window.api || !window.api.mcpStatus) return; + window.api.mcpStatus().then(function (s) { + status = s || status; + if (P()) P().rerender(); + const on = !!(P() && P().cfg && P().cfg().mcp && P().cfg().mcp.enabled); + if (on && status && !status.running && !status.portBusy && polls < 15) { + polls += 1; + setTimeout(refresh, 400); + } else { + polls = 0; + } + }).catch(function () { + status = Object.assign({}, status, { running: false, failed: true }); + if (P()) P().rerender(); + }); + } + + function ensure(cfg) { + if (!cfg.mcp || typeof cfg.mcp !== 'object') cfg.mcp = { enabled: false, mode: 'read', port: 38471 }; + if (['read', 'apply', 'write', 'full', 'everything'].indexOf(cfg.mcp.mode) < 0) cfg.mcp.mode = 'read'; + const port = Number(cfg.mcp.port); + if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 8722) cfg.mcp.port = 38471; + return cfg.mcp; + } + + function setEnabled(on) { + const mcp = ensure(P().cfg()); + mcp.enabled = !!on; + polls = 0; + status = Object.assign({}, status, { running: false, portBusy: false, failed: false, phase: on ? 'starting' : 'off' }); + if (P().rerender) P().rerender(); + P().push(true); + refresh(); + } + + function setMode(mode) { + const mcp = ensure(P().cfg()); + mcp.mode = mode; + if (P().rerender) P().rerender(); + P().push(true); + } + + function confirmPort(raw) { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > 65535 || n === 8722) { + P().toast(tx('mcp.port.invalid'), 'warn'); + return; + } + const mcp = ensure(P().cfg()); + const apply = function () { + mcp.port = n; + polls = 0; + status = Object.assign({}, status, { running: false, portBusy: false, failed: false, phase: 'starting' }); + if (P().rerender) P().rerender(); + P().push(true); + refresh(); + }; + const msg = tx('mcp.port.confirm').split('{port}').join(String(n)); + if (P().confirm) P().confirm(msg).then(function (ok) { if (ok) apply(); }); + else apply(); + } + + function toggle(label, checked, onChange) { + const el = P().el; + const input = el('input', { type: 'checkbox', checked: !!checked, onchange: function (e) { onChange(e.target.checked); } }); + return el('div', { class: 'ctrl mcp-toggle' }, [ + el('div', { class: 'row' }, [ + el('label', { class: 'lbl', text: label }), + el('label', { class: 'switch' }, [input, el('span', { class: 'track' })]), + ]), + ]); + } + + function statusText() { + if (status && status.running) return tx('mcp.card.listening') + ' 127.0.0.1:' + status.port; + if (status && (status.portBusy || status.failed)) return tx('mcp.card.failed'); + return tx('mcp.card.starting'); + } + + function fallbackCopy(value) { + try { + const ta = document.createElement('textarea'); + ta.value = value; + ta.setAttribute('readonly', 'readonly'); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (ok) { + P().toast(tx('mcp.card.copied'), 'ok'); + return; + } + } catch (e) { /* try the toast below */ } + P().toast(tx('mcp.card.copyFail'), 'warn'); + } + + function copy(text) { + const value = text == null ? '' : String(text); + if (!value) { + P().toast(tx('mcp.card.copyFail'), 'warn'); + return; + } + if (window.api && typeof window.api.copyToClipboard === 'function' && window.api.copyToClipboard(value)) { + P().toast(tx('mcp.card.copied'), 'ok'); + return; + } + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(value).then(function () { + P().toast(tx('mcp.card.copied'), 'ok'); + }, function () { fallbackCopy(value); }); + return; + } + fallbackCopy(value); + } + + function openSetup() { + const el = P().el; + const M = window.SVMcp; + const cfg = P().cfg(); + const model = M ? M.cardModel(cfg) : { port: 38471 }; + const script = (status && status.script) || 'mcp-stdio.js'; + const loc = window.SVI18n && window.SVI18n.locale; + const sections = M ? M.clients(script, model.port) : []; + const prompt = M && M.installPrompt ? M.installPrompt(script, loc, model.port) : ''; + const url = 'http://127.0.0.1:' + model.port + '/mcp'; + const body = sections.map(function (sec) { + return el('section', { class: 'mcp-client' }, [ + el('h4', { text: sec.title }), + el('ol', { class: 'mcp-steps' }, sec.steps.map(function (step) { return el('li', { text: tr(step) }); })), + el('pre', { class: 'mcp-snippet', text: sec.snippet }), + el('button', { + class: 'btn ghost small', type: 'button', text: tx('mcp.card.copy'), + onclick: function () { copy(sec.snippet); }, + }), + ]); + }); + const close = function () { backdrop.remove(); }; + const backdrop = el('div', { class: 'ask-backdrop mcp-backdrop' }, [ + el('div', { class: 'ask-panel mcp-setup', role: 'dialog', 'aria-modal': 'true' }, [ + el('div', { class: 'mcp-setup-head' }, [ + el('div', { class: 'ask-title', text: tx('mcp.setup.title') }), + el('button', { + class: 'btn ghost small mcp-setup-x', type: 'button', icon: 'x', + title: tx('mcp.card.close'), 'aria-label': tx('mcp.card.close'), + onclick: close, + }), + ]), + el('div', { class: 'mcp-prompt-block' }, [ + el('div', { class: 'mcp-setup-note', text: tx('mcp.setup.easy') }), + el('pre', { class: 'mcp-snippet', text: prompt }), + el('button', { + class: 'btn', type: 'button', text: tx('mcp.card.copyPrompt'), + onclick: function () { copy(prompt); }, + }), + el('div', { class: 'mcp-setup-note', text: tx('mcp.setup.http').split('{url}').join(url) }), + ]), + el('div', { class: 'mcp-manual' }, [ + el('h4', { class: 'mcp-manual-title', text: tx('mcp.setup.manual') }), + el('div', { class: 'mcp-clients' }, body), + ]), + el('div', { class: 'ask-actions' }, [ + el('button', { class: 'btn', type: 'button', text: tx('mcp.card.close'), onclick: close }), + ]), + ]), + ]); + backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); }); + document.body.appendChild(backdrop); + } + + function mcpCard() { + if (!loaded) { loaded = true; refresh(); } + const el = P().el; + const cfg = P().cfg(); + const model = window.SVMcp ? window.SVMcp.cardModel(cfg) : { enabled: false, mode: 'read', port: 38471, setupVisible: false, modes: [] }; + const on = model.enabled === true; + const nodes = [ + el('div', { class: 'studio-note dim-hint', text: tx('mcp.card.note') }), + toggle(tx('mcp.card.master'), on, setEnabled), + ]; + if (!on) return el('div', { class: 'mcp-card' }, nodes); + nodes.push(el('div', { class: 'mcp-status', text: statusText() })); + if (status && status.portBusy) { + const busy = status.requestedPort || status.configuredPort || model.port; + nodes.push(el('div', { class: 'studio-note', text: tx('mcp.port.busy').split('{port}').join(String(busy)) })); + } + const portInput = el('input', { type: 'number', min: '1', max: '65535', value: String(model.port || 38471) }); + nodes.push(el('div', { class: 'mcp-port' }, [ + el('label', { class: 'lbl', text: tx('mcp.port.label') }), + portInput, + el('button', { + class: 'btn ghost small', type: 'button', text: tx('mcp.port.apply'), + onclick: function () { confirmPort(portInput.value); }, + }), + ])); + nodes.push(el('div', { class: 'mcp-modes' }, (model.modes || []).map(function (id) { + return el('button', { + type: 'button', + class: 'mcp-mode' + (model.mode === id ? ' on' : ''), + onclick: function () { setMode(id); }, + }, [ + el('span', { class: 'mcp-mode-name', text: tx('mcp.mode.' + id + '.label') }), + el('span', { class: 'mcp-mode-hint', text: tx('mcp.mode.' + id + '.hint') }), + ]); + }))); + if (model.setupVisible) { + nodes.push(el('button', { class: 'btn', type: 'button', text: tx('mcp.card.setup'), onclick: openSetup })); + } + return el('div', { class: 'mcp-card' }, nodes); + } + + window.SVMcpPanel = { panel: mcpCard, refresh: refresh }; +})(); diff --git a/src/admin/nowplaying-panel.js b/src/admin/nowplaying-panel.js index 8618570..d39dab0 100644 --- a/src/admin/nowplaying-panel.js +++ b/src/admin/nowplaying-panel.js @@ -174,6 +174,16 @@ nodes.push(man('title', 'Parça Adı')); nodes.push(man('artist', 'Sanatçı')); nodes.push(man('album', 'Albüm')); + const clock = window.SVScenePanels; + nodes.push(P().row('Süre', el('input', { + class: 'p-in', type: 'text', + value: clock ? clock.formatClock(C.manual.duration) : '', + placeholder: '3:24', + oninput: (e) => { + C.manual.duration = clock ? clock.parseClock(e.target.value) : 0; + P().push(false); + }, + }))); } // ------------------------------------------------------------ görünürlük diff --git a/src/admin/preview.js b/src/admin/preview.js index 7f68c65..1de34e8 100644 --- a/src/admin/preview.js +++ b/src/admin/preview.js @@ -302,6 +302,15 @@ return true; } + /* Studio kaydı aynı katman anahtarını taşır; setConfig eski motoru + yeniden kullanır ve önizleme yeniden başlatılana kadar boş kalır. + Çıkış penceresi presets-delta'da yığını atıyor. Aynı iş burada. */ + function notePresets() { + if (!stack) return; + try { stack.dispose(); } catch { /* motor zaten kapanmış */ } + setConfig(cfg); + } + function setConfig(next) { cfg = window.SV.deepMerge(window.SV.defaultConfig(), next); if (!stack) return; @@ -337,6 +346,7 @@ window.SVPreview = { init, setConfig, + notePresets, // Panel canlı modülasyon göstergelerini buradan okur modulator: () => modulator, // Studio önizlemesi aynı ses motorunu kullanır: panelde iki ayrı analiz diff --git a/src/admin/record-panel.js b/src/admin/record-panel.js index af6d782..dd44424 100644 --- a/src/admin/record-panel.js +++ b/src/admin/record-panel.js @@ -347,5 +347,5 @@ P().rerender(); } - window.SVRecordPanel = { panel }; + window.SVRecordPanel = { panel: panel, start: start, stop: stop }; })(); diff --git a/src/admin/scene-panels.js b/src/admin/scene-panels.js index c330474..e975cc1 100644 --- a/src/admin/scene-panels.js +++ b/src/admin/scene-panels.js @@ -204,6 +204,26 @@ return false; } + /* "3:24" veya saniye. macOS/Linux medya oturumu okumaz; süre bu değerdir. */ + function parseClock(text) { + const s = String(text == null ? '' : text).trim(); + if (!s) return 0; + if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Number(s)); + const p = s.split(':').map((x) => Number(String(x).trim())); + if (p.some((n) => !Number.isFinite(n) || n < 0) || p.length < 2 || p.length > 3) return 0; + if (p.length === 2) return p[0] * 60 + p[1]; + return p[0] * 3600 + p[1] * 60 + p[2]; + } + function formatClock(sec) { + sec = Math.round(Number(sec) || 0); + if (sec <= 0) return ''; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const two = (n) => (n < 10 ? '0' + n : String(n)); + return h > 0 ? h + ':' + two(m) + ':' + two(s) : m + ':' + two(s); + } + function miniToggle(label, get, set, onAfter, opts) { const el = P().el; const disabled = !!(opts && opts.disabled); @@ -466,6 +486,224 @@ setTimeout(() => input.remove(), 1000); } + const NP_FONT_LABELS = [ + ['', 'Metin Katmanıyla Aynı'], + ['system-ui, -apple-system, Segoe UI, Roboto, sans-serif', 'Sistem'], + ['Georgia, "Times New Roman", serif', 'Serif'], + ['ui-monospace, "Cascadia Code", Consolas, monospace', 'Tek Aralıklı'], + ['Impact, "Arial Black", sans-serif', 'Ağır Başlık'], + ['"Trebuchet MS", "Segoe UI", sans-serif', 'Yuvarlak'], + ]; + const TEXT_FONT_LABELS = NP_FONT_LABELS.slice(1); + const NP_ANIM_LABELS = [ + ['none', 'Yok'], ['fade', 'Belirme'], ['slideUp', 'Yukarı Kayma'], + ['slideLeft', 'Yana Kayma'], ['scale', 'Büyüme'], + ['typewriter', 'Daktilo'], ['wipe', 'Perde'], + ]; + const TEXT_ANIM_LABELS = NP_ANIM_LABELS.slice(0, 5); + const ALIGN_LABELS = [['left', 'Sola'], ['center', 'Ortaya'], ['right', 'Sağa']]; + + /* Çalan Parça kartı katman yığını açıkken gizlenir. Motor + l.settings.nowplaying alanını kök ayarın üstüne yazar. Yatay ve dikey + konum Dönüşüm sekmesindedir; burada ikinci bir konum kaydırıcısı yok. + Geçen süre ve çubuk yalnız Windows medya oturumunda. macOS/Linux + süreyi elle yazar. */ + function nowplayingBag(l, cfg) { + l.settings = l.settings || {}; + const np = (l.settings.nowplaying = l.settings.nowplaying || {}); + const root = cfg.nowplaying || {}; + const get = (k, fb) => (np[k] !== undefined ? np[k] : (root[k] !== undefined ? root[k] : fb)); + const set = (k, val) => { np[k] = val; }; + const win = isWindowsPlatform(); + if (!win && get('source', 'manual') === 'system') set('source', 'manual'); + const fromSystem = win && get('source', 'system') === 'system'; + const showOf = () => Object.assign({}, root.show || {}, np.show || {}); + const setShow = (key, v) => { np.show = Object.assign({}, showOf(), { [key]: v }); }; + const style = (window.SVNowPlaying && window.SVNowPlaying.styleOf) + ? window.SVNowPlaying.styleOf(get('style', 'modern')) + : { weight: 600, outline: 0, shadow: 0.55, uppercase: false, barHeight: 0.005, barSegments: 0, barBackOpacity: 0.22 }; + const manual = () => { + np.manual = Object.assign({ title: '', artist: '', album: '', duration: 0 }, root.manual, np.manual); + return np.manual; + }; + return { np, get, set, win, fromSystem, showOf, setShow, style, manual }; + } + + function nowplayingLayerControls(l, cfg, rerender) { + const el = P().el; + const b = nowplayingBag(l, cfg); + const out = []; + if (b.win) { + out.push(miniSelect('Kaynak', [['system', 'Sistemden Oku'], ['manual', 'Elle Yaz']], + () => b.get('source', 'system'), (v) => b.set('source', v), rerender)); + } + if (!b.fromSystem) { + const textRow = (label, key, placeholder) => P().row(label, el('input', { + class: 'p-in', type: 'text', value: b.manual()[key] || '', + placeholder: placeholder || '', + oninput: (e) => { b.manual()[key] = e.target.value; P().push(false); }, + })); + out.push(textRow('Parça Adı', 'title')); + out.push(textRow('Sanatçı', 'artist')); + out.push(textRow('Albüm', 'album')); + out.push(P().row('Süre', el('input', { + class: 'p-in', type: 'text', value: formatClock(b.manual().duration), + placeholder: '3:24', + oninput: (e) => { b.manual().duration = parseClock(e.target.value); P().push(false); }, + }))); + } + out.push(miniSelect('Ne Zaman', [['always', 'Sürekli Görünsün'], ['onChange', 'Parça Değişince']], + () => b.get('mode', 'always'), (v) => b.set('mode', v), rerender)); + if (b.get('mode', 'always') === 'onChange') { + const sp = (window.SVNowPlaying && window.SVNowPlaying.speedOf) + ? window.SVNowPlaying.speedOf(b.get('speed', 'normal')) + : { anim: 0.5, hold: 4 }; + out.push(miniSelect('Hız', [['fast', 'Hızlı'], ['normal', 'Normal'], ['slow', 'Yavaş']], + () => b.get('speed', 'normal'), (v) => { + b.set('speed', v); + b.np.animDuration = null; + b.np.holdSeconds = null; + }, rerender)); + out.push(miniSlider('Ekranda Kalma', () => (b.get('holdSeconds', null) == null ? sp.hold : b.get('holdSeconds')), + (v) => b.set('holdSeconds', v), { min: 0.5, max: 20, step: 0.5, fmt: (v) => (+v).toFixed(1) + ' sn' })); + } + out.push(miniSelect('Hizalama', ALIGN_LABELS, () => b.get('align', 'center'), (v) => b.set('align', v))); + out.push(miniSlider('Boyut', () => (b.get('size', 0.042) == null ? 0.042 : b.get('size', 0.042)), + (v) => b.set('size', v), { min: 0.015, max: 0.16, step: 0.002, fmt: (v) => (v * 100).toFixed(1) + '%' })); + return out; + } + + function nowplayingTypeKids(l, cfg, rerender) { + const el = P().el; + const b = nowplayingBag(l, cfg); + const showToggle = (key, label, def) => miniToggle(label, + () => (b.showOf()[key] === undefined ? def : !!b.showOf()[key]), + (v) => b.setShow(key, v), rerender); + const out = [ + showToggle('title', 'Parça Adı', true), + showToggle('artist', 'Sanatçı', true), + showToggle('album', 'Albüm', false), + ]; + if (b.fromSystem) { + out.push(showToggle('appName', 'Oynatıcı Adı', false)); + out.push(showToggle('elapsed', 'Geçen Süre', true)); + out.push(showToggle('remaining', 'Kalan Süre', false)); + out.push(showToggle('total', 'Toplam Süre', true)); + out.push(showToggle('bar', 'İlerleme Çubuğu', true)); + } + out.push(miniToggle('Parça ve Sanatçı Tek Satırda', () => !!b.get('oneLine', false), (v) => b.set('oneLine', v), rerender)); + if (b.get('oneLine', false)) { + out.push(P().row('Ayırıcı', el('input', { + class: 'p-in', type: 'text', value: b.get('separator', ' — '), + oninput: (e) => { b.set('separator', e.target.value); P().push(false); }, + }))); + } + if (b.fromSystem) { + out.push(P().row('Süre Ayırıcı', el('input', { + class: 'p-in', type: 'text', value: b.get('timeSeparator', ' / '), + oninput: (e) => { b.set('timeSeparator', e.target.value); P().push(false); }, + }))); + } + out.push(miniSelect('Kalıp', [['modern', 'Modern'], ['og', 'OG (Klasik)']], + () => b.get('style', 'modern'), (v) => { + b.set('style', v); + b.np.weight = null; b.np.outline = null; b.np.shadow = null; + b.np.uppercase = null; b.np.barHeight = null; b.np.barSegments = null; + b.np.barRadius = null; b.np.barBackOpacity = null; + }, rerender)); + out.push(miniSelect('Yazı Tipi', NP_FONT_LABELS, () => b.get('font', ''), (v) => b.set('font', v))); + out.push(miniSlider('Kalınlık', () => (b.get('weight', null) == null ? b.style.weight : b.get('weight')), + (v) => b.set('weight', Math.round(v / 100) * 100), + { min: 100, max: 900, step: 100, fmt: (v) => String(Math.round(v / 100) * 100) })); + out.push(miniToggle('Büyük Harf', () => (b.get('uppercase', null) == null ? !!b.style.uppercase : !!b.get('uppercase')), + (v) => b.set('uppercase', v))); + out.push(miniSlider('Satır Aralığı', () => (b.get('lineGap', 0.32) == null ? 0.32 : b.get('lineGap', 0.32)), + (v) => b.set('lineGap', v), { min: 0, max: 1.2, step: 0.02 })); + out.push(miniSlider('Yazı Saydamlığı', () => (b.get('opacity', 1) == null ? 1 : b.get('opacity', 1)), + (v) => b.set('opacity', v), { min: 0, max: 1, step: 0.01, percent: true })); + out.push(miniSlider('Kontur', () => (b.get('outline', null) == null ? b.style.outline : b.get('outline')), + (v) => b.set('outline', v), { min: 0, max: 1, step: 0.02 })); + out.push(miniSlider('Gölge', () => (b.get('shadow', null) == null ? b.style.shadow : b.get('shadow')), + (v) => b.set('shadow', v), { min: 0, max: 1, step: 0.02 })); + out.push(miniSlider('En Fazla Genişlik', () => (b.get('maxWidth', 0.8) == null ? 0.8 : b.get('maxWidth', 0.8)), + (v) => b.set('maxWidth', v), { min: 0.2, max: 1, step: 0.01, percent: true })); + out.push(miniToggle('Uzun Adları Kaydır', () => b.get('scrollLongTitles', true) !== false, + (v) => b.set('scrollLongTitles', v))); + if (b.fromSystem && b.showOf().bar !== false) { + out.push(miniSlider('Genişlik', () => (b.get('barWidth', 0.42) == null ? 0.42 : b.get('barWidth', 0.42)), + (v) => b.set('barWidth', v), { min: 0.05, max: 1, step: 0.01, percent: true })); + out.push(miniSlider('Çubuk Kalınlığı', () => (b.get('barHeight', null) == null ? b.style.barHeight : b.get('barHeight')), + (v) => b.set('barHeight', v), { min: 0.001, max: 0.04, step: 0.001, fmt: (v) => (v * 100).toFixed(1) + '%' })); + out.push(miniSlider('Bölme Sayısı', () => (b.get('barSegments', null) == null ? b.style.barSegments : b.get('barSegments')), + (v) => b.set('barSegments', Math.round(v)), + { min: 0, max: 80, step: 1, fmt: (v) => (v < 1 ? 'kesintisiz' : Math.round(v) + ' bölme') })); + out.push(miniSlider('Yazıyla Arası', () => (b.get('barGap', 0.5) == null ? 0.5 : b.get('barGap', 0.5)), + (v) => b.set('barGap', v), { min: 0, max: 2, step: 0.05 })); + out.push(miniSlider('Zemin Koyuluğu', () => (b.get('barBackOpacity', null) == null ? b.style.barBackOpacity : b.get('barBackOpacity')), + (v) => b.set('barBackOpacity', v), { min: 0, max: 1, step: 0.02, percent: true })); + } + return out; + } + + function nowplayingMotionKids(l, cfg) { + const b = nowplayingBag(l, cfg); + const spAnim = (window.SVNowPlaying && window.SVNowPlaying.speedOf) + ? window.SVNowPlaying.speedOf(b.get('speed', 'normal')) + : { anim: 0.5 }; + return [ + miniSelect('Giriş', NP_ANIM_LABELS, () => b.get('animation', 'slideUp'), (v) => b.set('animation', v)), + miniSlider('Giriş Süresi', () => (b.get('animDuration', null) == null ? spAnim.anim : b.get('animDuration')), + (v) => b.set('animDuration', v), { min: 0.05, max: 3, step: 0.05, fmt: (v) => (+v).toFixed(2) + ' sn' }), + ]; + } + + function nowplayingAudioKids(l, cfg) { + const b = nowplayingBag(l, cfg); + return [ + miniSlider('Basla Nabız', () => (b.get('audioScale', 0.04) == null ? 0.04 : b.get('audioScale', 0.04)), + (v) => b.set('audioScale', v), { min: 0, max: 0.4, step: 0.01 }), + ]; + } + + /* Metin kartı da yığın açıkken gizlenir. Konum Dönüşüm sekmesinde, + nabız Sese Tepki sekmesinde, giriş Hareket sekmesindedir. */ + function textTypeKids(txt) { + return [ + miniSelect('Yazı Tipi', TEXT_FONT_LABELS, () => txt.font || TEXT_FONT_LABELS[0][0], (v) => { txt.font = v; }), + miniSlider('Kalınlık', () => txt.weight || 700, (v) => { txt.weight = Math.round(v / 100) * 100; }, + { min: 100, max: 900, step: 100, fmt: (v) => String(Math.round(v / 100) * 100) }), + miniSlider('Yazı Saydamlığı', () => (txt.opacity == null ? 1 : txt.opacity), (v) => { txt.opacity = v; }, + { min: 0, max: 1, step: 0.01, percent: true }), + miniSlider('Kontur', () => (txt.outline == null ? 0 : txt.outline), (v) => { txt.outline = v; }, + { min: 0, max: 1, step: 0.02 }), + miniSlider('Gölge', () => (txt.shadow == null ? 0 : txt.shadow), (v) => { txt.shadow = v; }, + { min: 0, max: 1, step: 0.02 }), + ]; + } + + function textMotionKids(txt) { + return [ + miniSelect('Giriş', TEXT_ANIM_LABELS, () => txt.animation || 'fade', (v) => { txt.animation = v; }), + miniSlider('Giriş Süresi', () => (txt.animDuration == null ? 0.45 : txt.animDuration), + (v) => { txt.animDuration = v; }, { min: 0.05, max: 2, step: 0.05, fmt: (v) => (+v).toFixed(2) + ' sn' }), + ]; + } + + function textAudioKids(txt, rerender) { + const out = [ + miniSlider('Basla Nabız', () => (txt.audioScale == null ? 0.12 : txt.audioScale), + (v) => { txt.audioScale = v; }, { min: 0, max: 0.6, step: 0.01 }), + miniSlider('Titreşim', () => txt.audioJitter || 0, (v) => { txt.audioJitter = v; }, + { min: 0, max: 1, step: 0.02 }), + miniToggle('Harf Harf Tepki', () => !!txt.perCharacter, (v) => { txt.perCharacter = v; }, rerender), + ]; + if (txt.perCharacter) { + out.push(miniSlider('Harf Yükselmesi', () => (txt.audioLift == null ? 0.25 : txt.audioLift), + (v) => { txt.audioLift = v; }, { min: 0, max: 1, step: 0.02 })); + } + return out; + } + /* Bir katmanın kendi kaynağına ait ayarlar. Her katman eklenen modun (Barlar, Dalga, Çember, Gradyan vb.) kendine @@ -670,6 +908,13 @@ P().push(true); rerender(); }, + onRemove: (it) => { + if (lg.libraryId !== it.id) return; + lg.libraryId = ''; + if (!lg.src) lg.kind = ''; + P().push(true); + rerender(); + }, })); } return out; @@ -927,6 +1172,10 @@ }, }), ])); + out.push(miniSlider('Senkron Kayması', () => txt.offset || 0, (v) => { txt.offset = v; }, { + min: -10, max: 10, step: 0.05, fmt: (v) => (v > 0 ? '+' : '') + (+v).toFixed(2) + ' sn', + })); + out.push(miniToggle('Karaoke Vurgusu', () => txt.karaoke !== false, (v) => { txt.karaoke = v; })); } const getTxtMode = () => (cfg.visualizer && cfg.visualizer.colorMode) @@ -947,7 +1196,7 @@ out.push(miniColor('Vurgu Rengi', () => txt.colorHighlight || '#ffd23f', (v) => { txt.colorHighlight = v; })); } out.push(miniSlider('Yazı Boyutu', () => txt.size == null ? 0.08 : txt.size, (v) => { txt.size = v; }, { min: 0.01, max: 0.3, step: 0.005 })); - out.push(miniSelect('Hizalama', [['left', 'Sola'], ['center', 'Ortaya'], ['right', 'Sağa']], () => txt.align || 'center', (v) => { txt.align = v; })); + out.push(miniSelect('Hizalama', ALIGN_LABELS, () => txt.align || 'center', (v) => { txt.align = v; })); return out; } @@ -1020,6 +1269,7 @@ // Album cover overlay for Now Playing layers (default off). // Layer stack hides the dedicated Now Playing card (notStack), so expose here. if (l.type === 'nowplaying') { + out.push.apply(out, nowplayingLayerControls(l, cfg, rerender)); const npCover = (l.settings.nowplaying = l.settings.nowplaying || {}); const getCover = (k, fb) => { if (npCover[k] !== undefined) return npCover[k]; @@ -1591,6 +1841,23 @@ ], }, ]; + if (l.kind === 'nowplaying' || (l.kind === 'visualizer' && l.type === 'nowplaying')) { + const cfgNp = P().cfg(); + tabs.splice(1, 0, + { key: 'type', label: 'Yazı', build: () => nowplayingTypeKids(l, cfgNp, rerender) }, + { key: 'motion', label: 'Hareket', build: () => nowplayingMotionKids(l, cfgNp) }); + const audioNp = tabs.find((t) => t.key === 'audio'); + const prevNp = audioNp.build; + audioNp.build = () => prevNp().concat(nowplayingAudioKids(l, cfgNp)); + } else if (l.kind === 'visualizer' && l.type === 'text' && l.settings && l.settings.text) { + const txt = l.settings.text; + tabs.splice(1, 0, + { key: 'type', label: 'Yazı', build: () => textTypeKids(txt) }, + { key: 'motion', label: 'Hareket', build: () => textMotionKids(txt) }); + const audioTx = tabs.find((t) => t.key === 'audio'); + const prevTx = audioTx.build; + audioTx.build = () => prevTx().concat(textAudioKids(txt, rerender)); + } body.push.apply(body, layerTabs(key, tabs)); // ---- Kopyala / çoğalt ---- @@ -1963,6 +2230,6 @@ layersPanel, effectsPanel, geometryPanel, artnetPanel, // Ortak satır üreticileri — diğer paneller de aynı görünümü kullansın miniSlider, miniSelect, miniSegment, miniToggle, foldable, itemHeader, moveItem, - isWindows: isWindowsPlatform, + isWindows: isWindowsPlatform, parseClock, formatClock, }; })(); diff --git a/src/admin/studio.js b/src/admin/studio.js index 7350880..47de541 100644 Binary files a/src/admin/studio.js and b/src/admin/studio.js differ diff --git a/src/main/main.js b/src/main/main.js index 349a149..1857f81 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -26,6 +26,7 @@ const appCapture = require('./app-capture'); const instances = require('./instances'); const { SettingsGuard } = require('./settings-guard'); const logoLibrary = require('./logo-library'); +const mcpServer = require('./mcp-server'); // Medya katmanının video dosyalarını okuduğu özel protokol. // Sayfa file:// (masaüstü) veya http:// (OBS) olsun, CSP tek bir kaynağa @@ -710,13 +711,19 @@ function sendToVisualizers(channel, payload) { if (ts) ts.webContents.send(channel, channel === 'config' ? configForTextureShare(payload) : payload); } -// İstenen ekran kimliklerini çöz (tek sayı, dizi veya boş kabul edilir) +// İstenen ekran kimliklerini çöz. Boş veya bilinmeyen seçim boş döner; +// birincil ekrana düşmek, panelde hiçbir kutu seçilmemesini geri açıyordu. function resolveDisplayIds(input) { const all = screen.getAllDisplays(); - const raw = Array.isArray(input) ? input : input == null ? [] : [input]; - const wanted = raw.map(Number).filter((id) => all.some((d) => d.id === id)); - if (wanted.length) return Array.from(new Set(wanted)); - return [screen.getPrimaryDisplay().id]; + if (input == null || input === '') return []; + const raw = Array.isArray(input) ? input : [input]; + const wanted = []; + for (const id of raw) { + const n = Number(id); + if (!all.some((d) => d.id === n)) continue; + if (wanted.indexOf(n) === -1) wanted.push(n); + } + return wanted; } function createVisualizerWindow(display) { @@ -1810,6 +1817,169 @@ function recreateVisualizerWindows() { } // Admin -> ana süreç -> görselleştirici (yapılandırma güncellemesi) + +let mcpHandle = null; +const mcpLive = { bpm: 0, confidence: 0, level: 0, bass: 0, mid: 0, treble: 0, at: 0 }; + +function noteMcpLive(data) { + if (!data || typeof data !== 'object') return; + if (data.level != null) mcpLive.level = data.level; + if (data.bass != null) mcpLive.bass = data.bass; + if (data.mid != null) mcpLive.mid = data.mid; + if (data.treble != null) mcpLive.treble = data.treble; + if (data.bpm != null) mcpLive.bpm = data.bpm; + if (data.confidence != null) mcpLive.confidence = data.confidence; + mcpLive.at = Date.now(); +} + +const VISUALIZER_READY_JS = '(() => {' + + 'const c = document.querySelector("canvas");' + + 'if (!c || c.width < 8 || c.height < 8) return false;' + + 'return true;' + + '})()'; + +const ADMIN_PREVIEW_RECT_JS = '(() => {' + + 'const P = window.SVPreview;' + + 'if (!P || typeof P.stack !== "function" || !P.stack()) return null;' + + 'const stage = document.getElementById("previewStage");' + + 'if (!stage) return null;' + + 'const r = stage.getBoundingClientRect();' + + 'if (r.width < 8 || r.height < 8) return null;' + + 'return { x: Math.max(0, Math.floor(r.x)), y: Math.max(0, Math.floor(r.y)), width: Math.max(1, Math.round(r.width)), height: Math.max(1, Math.round(r.height)) };' + + '})()'; + +const PREVIEW_NOT_READY = 'The preview canvas is not ready.'; + +function jpegOfShown(image) { + if (!image || image.isEmpty()) return null; + const size = image.getSize(); + if (!size || size.width < 8 || size.height < 8) return null; + let out = image; + if (size.width > 480) { + const height = Math.max(1, Math.round(size.height * (480 / size.width))); + out = image.resize({ width: 480, height: height }); + } + const sized = out.getSize(); + const buf = out.toJPEG(62); + if (!buf || !buf.length) return null; + return { + width: sized.width, + height: sized.height, + dataUrl: 'data:image/jpeg;base64,' + buf.toString('base64'), + }; +} + +async function captureShown(win, rect) { + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return null; + const image = rect ? await win.webContents.capturePage(rect) : await win.webContents.capturePage(); + return jpegOfShown(image); +} + +/* Canvas toDataURL reads the WebGL drawing buffer, which is already empty + after the frame is shown, so the JPEG was 480px and fully black. capturePage + takes the pixels the window is actually showing. */ +async function mcpCapturePreview() { + const shown = []; + for (const win of visualizerWins.values()) shown.push(win); + if (floatingWin && !floatingWin.isDestroyed()) shown.push(floatingWin); + for (const win of shown) { + if (!win || win.isDestroyed()) continue; + try { + const ready = await win.webContents.executeJavaScript(VISUALIZER_READY_JS); + if (!ready) continue; + const img = await captureShown(win); + if (img) return img; + } catch (e) { /* this window has no frame yet */ } + } + if (adminWin && !adminWin.isDestroyed()) { + try { + const rect = await adminWin.webContents.executeJavaScript(ADMIN_PREVIEW_RECT_JS); + if (rect && rect.width >= 8 && rect.height >= 8) { + const img = await captureShown(adminWin, rect); + if (img) return img; + } + } catch (e) { /* admin preview is not up */ } + } + return { error: PREVIEW_NOT_READY }; +} + +function mcpAdminCall(js) { + if (!adminWin || adminWin.isDestroyed()) return Promise.resolve({ ok: false, error: 'Admin window is not open.' }); + return adminWin.webContents.executeJavaScript(js); +} + +function ensureMcp() { + if (mcpHandle) return mcpHandle; + mcpHandle = mcpServer.create({ + userData: app.getPath('userData'), + getConfig: () => currentConfig, + setConfig: (cfg) => { + applyIncomingConfig(cfg); + notifyAdmin('external-config', currentConfig); + }, + live: () => Object.assign({}, mcpLive), + nowPlaying: () => mediaSession.current(), + displays: () => getDisplayList(), + outputStatus: () => ({ open: anyVisualizerOpen(), ids: Array.from(visualizerWins.keys()) }), + presets: { + list: () => presetsStore.list(), + get: (id) => presetsStore.get(id), + save: (preset) => { + const r = presetsStore.save(preset); + if (r && r.ok && r.preset) broadcastPresetDelta([r.preset], []); + return r; + }, + remove: (id) => { + const r = presetsStore.remove(id); + if (r && r.ok) broadcastPresetDelta([], [String(id)]); + return r; + }, + }, + /* Her MCP turu klasörün adlarına bakıyor. Araç dosyayı deponun + kaydından değil de yanından yazdıysa açık pencereler aynı turda + görüyor. Bütün dosyaları stat'lamıyor. */ + syncPresets: () => { + const delta = presetsStore.syncDisk(); + if (delta.upsert.length || delta.remove.length) broadcastPresetDelta(delta.upsert, delta.remove); + }, + openOutput: (ids) => openVisualizer(ids), + closeOutput: (id) => closeVisualizer(id), + setFloatingOpen: (open) => { + if (open) { + createFloatingWindow(); + return { open: floatingIsOpen() }; + } + closeFloatingWindow(); + return { open: floatingIsOpen() }; + }, + stopClips: () => mcpAdminCall('(() => { const C = window.SVClipDeckPanel; if (!C || !C.stopAll) return { ok:false, error:"Clip deck is not loaded." }; C.stopAll(); return { ok:true }; })()'), + startExport: (opts) => startExportJob(opts), + cancelExport: () => { if (exportState) exportState.cancel = true; return true; }, + writeText: (file, text) => { fs.writeFileSync(file, text, 'utf8'); return true; }, + writeBinary: (file, buf) => { fs.writeFileSync(file, buf); return true; }, + capturePreview: () => mcpCapturePreview(), + recordStart: () => mcpAdminCall('(async () => { const R = window.SVRecordPanel; if (!R || !R.start) return { ok:false, error:"Recorder is not loaded." }; await R.start(window.SVPanel.cfg()); return { ok:true }; })()'), + recordStop: () => mcpAdminCall('(async () => { const R = window.SVRecordPanel; if (!R || !R.stop) return { ok:false, error:"Recorder is not loaded." }; await R.stop(); return { ok:true }; })()'), + timeline: (action, time) => mcpAdminCall('(() => { const T = window.SVTimelinePanel; const a = ' + JSON.stringify({ action: action, time: time }) + '; if (!T) return { ok:false, error:"Timeline is not loaded." }; if (a.action==="play" && T.play) T.play(); else if (a.action==="pause" && T.pause) T.pause(); else if (a.action==="stop" && T.stop) T.stop(); else if (a.action==="seek" && T.seek) T.seek(Number(a.time)||0); else return { ok:false, error:"Unknown transport action." }; const tr = T.transport ? T.transport() : null; return { ok:true, playing:!!(tr&&tr.playing), time: tr ? tr.time : null }; })()'), + launchClip: (args) => mcpAdminCall('(() => { const C = window.SVClipDeckPanel; if (!C || !C.launchSlot) return { ok:false, error:"Clip deck is not loaded." }; C.launchSlot(' + Number(args && args.row) + ',' + Number(args && args.col) + '); return { ok:true }; })()'), + locale: () => appLocale(), + }); + return mcpHandle; +} + +function syncMcp() { + try { + const enabled = !!(currentConfig && currentConfig.mcp && currentConfig.mcp.enabled === true); + return ensureMcp().sync(enabled); + } catch (e) { + console.error('[MCP] sync failed:', e); + return Promise.resolve(null); + } +} + +ipcMain.handle('mcp:status', () => (mcpHandle ? mcpHandle.status() : { enabled: false, running: false, host: '127.0.0.1', port: 0, shell: '', script: '' })); +ipcMain.on('mcp:live', (e, data) => noteMcpLive(data)); + ipcMain.on('update-config', (e, config) => applyIncomingConfig(config)); /* Yeni yapılandırmayı her yere uygular. Panelden gelen yapılandırma @@ -1869,6 +2039,7 @@ function applyIncomingConfig(config, opts) { // Ses kaynağı değiştiyse yakalamayı yeniden başlat. Bu, görselleştirici kapalıyken // yalnızca panel önizlemesi dinliyor olsa da geçerlidir. syncCapture(); + syncMcp(); } // Aynı ayar klasörünü kullanan kopyalar ve ayar dosyası çakışması (#564) @@ -2019,6 +2190,7 @@ ipcMain.on('md-live', (e, mp) => { }); ipcMain.on('audio-meter', (e, data) => { + noteMcpLive(data); const primary = meterWindow(); if (primary && e.sender !== primary.webContents) return; if (data && data.mdPreset) relayMdFollow(e.sender, data.mdPreset); @@ -2065,17 +2237,78 @@ function notifyAll(channel, payload) { kilidi. Şimdi yalnız değişenler gidiyor (`presets-delta`); bütün liste yalnız sayfa açılırken isteniyor (`presets:list`). Web istemcilerine MilkDrop kaynakları hiç gitmiyor (stream-server.js `publicPresets`). */ +/* Açık pencere listeyi isterken kaçırdığı yayını buradan tamamlıyor. + Gövdeler en fazla 32 kayıt tutuluyor; büyük bir içe aktarımın kaynakları + ikinci kez bellekte durmuyor — kaçıran sayfa listeyi yeniden istiyor. */ +let presetGen = 0; +const presetJournal = []; + +/* Seçili MilkDrop presetinin dosyası değişince ayardaki kopya da değişsin. + Yoksa bir sonraki kaydırıcı eski kaynağı geri yazar ve ekran eski presete + döner. Yapılandırmanın tamamı yeniden uygulanmıyor: ışık ve yakalama + durur. */ +function followActivePreset(upsert) { + if (!currentConfig || !currentConfig.milkdrop || !upsert || !upsert.length) return; + const id = currentConfig.milkdrop.presetId; + if (!id) return; + for (let i = 0; i < upsert.length; i++) { + const p = upsert[i]; + if (!p || p.kind !== 'milkdrop' || p.id !== id || typeof p.source !== 'string') continue; + if (p.source === currentConfig.milkdrop.source) continue; + currentConfig.milkdrop = Object.assign({}, currentConfig.milkdrop, { + source: p.source, + name: p.name || currentConfig.milkdrop.name, + }); + sendToVisualizers('config', currentConfig); + notifyAdmin('external-config', currentConfig); + streamServer.broadcast({ type: 'config', config: currentConfig }); + saveSettings(currentConfig); + return; + } +} + function broadcastPresetDelta(upsert, remove) { - const delta = { upsert: upsert || [], remove: remove || [] }; - if (!delta.upsert.length && !delta.remove.length) return; + const up = upsert || []; + const rm = remove || []; + if (!up.length && !rm.length) return; + presetGen += 1; + const bulk = up.length > 24; + presetJournal.push({ gen: presetGen, upsert: bulk ? [] : up, remove: rm, bulk: bulk }); + if (presetJournal.length > 32) presetJournal.shift(); + const delta = { upsert: up, remove: rm, gen: presetGen }; notifyAll('presets-delta', delta); streamServer.broadcast({ type: 'presets-delta', delta }); + followActivePreset(up); } // Açılışta arka planda okunmuş olabilir; değilse okuma burada bitiyor ipcMain.handle('presets:list', async () => { await presetsStore.warm(); - return presetsStore.list(); + const list = presetsStore.list(); + /* `list()` yeni adı önbelleğe alır ama bunu yayınlamazsa diğer pencereler + eski listede kalır. Aynı turda biriken fark burada gidiyor. */ + const disk = presetsStore.syncDisk(); + if (disk.upsert.length || disk.remove.length) broadcastPresetDelta(disk.upsert, disk.remove); + return list; +}); +ipcMain.handle('presets:head', () => ({ gen: presetGen })); +ipcMain.handle('presets:since', (e, gen) => { + const disk = presetsStore.syncDisk(); + if (disk.upsert.length || disk.remove.length) broadcastPresetDelta(disk.upsert, disk.remove); + const g = Number(gen) || 0; + if (presetJournal.length && g > 0 && g < presetJournal[0].gen) { + return { gen: presetGen, reset: true, upsert: [], remove: [] }; + } + const upsert = []; + const remove = []; + let bulk = false; + for (const entry of presetJournal) { + if (entry.gen <= g) continue; + if (entry.bulk) bulk = true; + else upsert.push.apply(upsert, entry.upsert); + remove.push.apply(remove, entry.remove); + } + return { gen: presetGen, bulk, upsert, remove }; }); ipcMain.handle('presets:save', (e, preset) => { const r = presetsStore.save(preset); @@ -3497,7 +3730,12 @@ app.whenReady().then(async () => { /* Preset deposu arka planda okunmaya başlıyor (#574): pencereler açılıp listeyi isteyene kadar binlerce dosyanın okuması büyük ölçüde bitmiş oluyor ve ana süreç o arada bekletilmiyor. */ - presetsStore.warm().catch(() => {}); + presetsStore.warm().then(() => { + presetsStore.watch((delta) => { + if (delta && (delta.upsert.length || delta.remove.length)) broadcastPresetDelta(delta.upsert, delta.remove); + }); + }).catch(() => {}); + syncMcp(); // Medya katmanının video dosyalarını okuduğu protokol. Yalnızca // yapılandırmada SEÇİLİ olan dosyayı açar; sayfaya genel dosya sistemi @@ -5857,6 +6095,7 @@ function shutdownCleanup() { textureShare.stop().catch(() => {}); mediaSession.stop(); nativeAudio.stopCapture(); + if (mcpHandle) mcpHandle.stop().catch(() => {}); } app.on('before-quit', (e) => { diff --git a/src/main/mcp-server.js b/src/main/mcp-server.js new file mode 100644 index 0000000..d532937 --- /dev/null +++ b/src/main/mcp-server.js @@ -0,0 +1,229 @@ +'use strict'; +/* Loopback MCP endpoint. Binds 127.0.0.1 only and only while mcp.enabled is on. + Claude, Cursor, and Codex spawn src/main/mcp-stdio.js, which forwards here. */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const mcp = require('../shared/mcp'); + +const BIND_HOST = '127.0.0.1'; +const DEFAULT_PORT = 38471; + +function assertLoopback(host) { + if (host !== BIND_HOST) { + throw new Error('MCP transport refuses non-loopback host: ' + host); + } +} + +function isLoopbackAddress(addr) { + return addr === '127.0.0.1' || addr === '::ffff:127.0.0.1' || addr === '::1'; +} + +function create(deps) { + deps = deps || {}; + let server = null; + let token = ''; + let port = 0; + let portBusy = false; + let busyPort = 0; + let endpointFile = ''; + let revision = 0; + + function ctx() { + return { + getConfig: deps.getConfig, + setConfig: function (cfg) { + if (deps.setConfig) deps.setConfig(cfg); + }, + noteWrite: function () { revision += 1; return revision; }, + revision: function () { return revision; }, + live: deps.live, + nowPlaying: deps.nowPlaying, + displays: deps.displays, + outputStatus: deps.outputStatus, + presets: deps.presets, + openOutput: deps.openOutput, + closeOutput: deps.closeOutput, + setFloatingOpen: deps.setFloatingOpen, + stopClips: deps.stopClips, + startExport: deps.startExport, + cancelExport: deps.cancelExport, + writeText: deps.writeText, + writeBinary: deps.writeBinary, + capturePreview: deps.capturePreview, + recordStart: deps.recordStart, + recordStop: deps.recordStop, + timeline: deps.timeline, + launchClip: deps.launchClip, + locale: deps.locale, + }; + } + + function scriptPath() { + return path.join(deps.userData, 'mcp-stdio.js'); + } + + function status() { + const script = scriptPath(); + const bundle = mcp.commandBundle(script); + const cfg = deps.getConfig ? deps.getConfig() : null; + return { + enabled: !!(cfg && mcp.normalizeMcp(cfg.mcp).enabled), + running: !!(server && server.listening), + host: BIND_HOST, + port: port || 0, + script: script, + command: bundle.command, + args: bundle.args, + shell: bundle.shell, + url: bundle.url, + configuredPort: configuredPort(), + portBusy: portBusy, + requestedPort: portBusy ? busyPort : (port || configuredPort()), + }; + } + + function configuredPort() { + const cfg = deps.getConfig ? deps.getConfig() : null; + const raw = cfg && cfg.mcp ? Number(cfg.mcp.port) : DEFAULT_PORT; + if (!Number.isInteger(raw) || raw < 1 || raw > 65535 || raw === 8722) return DEFAULT_PORT; + return raw; + } + + function writeEndpoint() { + endpointFile = path.join(deps.userData, 'mcp-endpoint.json'); + fs.mkdirSync(deps.userData, { recursive: true }); + const bridge = fs.readFileSync(path.join(__dirname, 'mcp-stdio.js')); + fs.writeFileSync(scriptPath(), bridge); + fs.writeFileSync(endpointFile, JSON.stringify({ host: BIND_HOST, port: port, token: token }, null, 2)); + /* Uç nokta belirteci taşır. Linux ve macOS'ta dosya yalnız bu kullanıcı + okusun; Windows chmod'u yok sayar, orada kullanıcı profili yeter. */ + if (process.platform !== 'win32') { + try { fs.chmodSync(scriptPath(), 0o600); } catch (e) { /* yok */ } + try { fs.chmodSync(endpointFile, 0o600); } catch (e) { /* yok */ } + } + } + + function clearEndpoint() { + if (!deps.userData) return; + for (const name of ['mcp-endpoint.json']) { + try { fs.unlinkSync(path.join(deps.userData, name)); } catch (e) { /* already gone */ } + } + } + + function onRequest(req, res) { + const remote = req.socket && req.socket.remoteAddress; + if (!isLoopbackAddress(remote)) { + res.writeHead(403); + res.end('loopback only'); + return; + } + if (req.method !== 'POST' || req.url !== '/mcp') { + res.writeHead(404); + res.end(); + return; + } + const auth = String(req.headers.authorization || ''); + if (auth !== 'Bearer ' + token) { + res.writeHead(401); + res.end('unauthorized'); + return; + } + const chunks = []; + req.on('data', function (c) { chunks.push(c); }); + req.on('end', function () { + let msg; + try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch (e) { + res.writeHead(400); + res.end('bad json'); + return; + } + Promise.resolve(mcp.handleRpc(msg, ctx())).then(function (out) { + try { if (deps.syncPresets) deps.syncPresets(); } catch (e) { /* klasör okunamadı */ } + if (out == null) { + res.writeHead(202); + res.end(); + return; + } + const body = JSON.stringify(out); + res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + res.end(body); + }).catch(function (e) { + try { if (deps.syncPresets) deps.syncPresets(); } catch (err) { /* klasör okunamadı */ } + const body = JSON.stringify({ jsonrpc: '2.0', id: msg && msg.id, error: { code: -32603, message: String((e && e.message) || e) } }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(body); + }); + }); + } + + function start(opts) { + const host = (opts && opts.host) || BIND_HOST; + assertLoopback(host); + if (host !== BIND_HOST) throw new Error('MCP listens on 127.0.0.1 only'); + const want = (opts && Number.isInteger(Number(opts.port)) && Number(opts.port) > 0) ? Number(opts.port) : configuredPort(); + if (want === 8722) return Promise.reject(new Error('MCP will not use the stream port 8722')); + if (server && server.listening && port === want) return Promise.resolve(status()); + const begin = function () { + token = crypto.randomBytes(24).toString('hex'); + server = http.createServer(onRequest); + portBusy = false; + busyPort = 0; + return new Promise(function (resolve, reject) { + server.once('error', function (err) { + server = null; + port = 0; + if (err && err.code === 'EADDRINUSE') { + portBusy = true; + busyPort = want; + try { clearEndpoint(); } catch (e) { /* ignore */ } + resolve(status()); + return; + } + reject(err); + }); + server.listen(want, BIND_HOST, function () { + const addr = server.address(); + if (!addr || (addr.address !== BIND_HOST && addr.address !== '::ffff:127.0.0.1') || addr.port !== want) { + try { server.close(); } catch (e) { /* ignore */ } + server = null; + port = 0; + portBusy = true; + busyPort = want; + resolve(status()); + return; + } + port = addr.port; + portBusy = false; + try { writeEndpoint(); } catch (e) { reject(e); return; } + resolve(status()); + }); + }); + }; + if (server) return stop().then(begin); + return begin(); + } + + function stop() { + clearEndpoint(); + port = 0; + token = ''; + if (!server) return Promise.resolve(status()); + const closing = server; + server = null; + return new Promise(function (resolve) { + closing.close(function () { resolve(status()); }); + }); + } + + function sync(enabled) { + if (enabled) return start(); + return stop(); + } + + return { start: start, stop: stop, sync: sync, status: status }; +} + +module.exports = { BIND_HOST: BIND_HOST, DEFAULT_PORT: DEFAULT_PORT, assertLoopback: assertLoopback, create: create }; diff --git a/src/main/mcp-stdio.js b/src/main/mcp-stdio.js new file mode 100644 index 0000000..36d8877 --- /dev/null +++ b/src/main/mcp-stdio.js @@ -0,0 +1,131 @@ +'use strict'; +/* Stdio MCP bridge. Clients spawn this file. It speaks MCP on stdin/stdout + and forwards JSON-RPC to the app, which listens on 127.0.0.1 only. */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +function isLoopbackHost(host) { + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; +} + +function endpointFrom(raw) { + const data = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!data || !isLoopbackHost(data.host)) { + throw new Error('Refusing non-loopback MCP endpoint'); + } + const port = Number(data.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('Invalid MCP port'); + return { host: '127.0.0.1', port: port, token: data.token || '' }; +} + +function endpointPath() { + const i = process.argv.indexOf('--endpoint'); + if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; + return path.join(__dirname, 'mcp-endpoint.json'); +} + +function loadEndpoint() { + return endpointFrom(fs.readFileSync(endpointPath(), 'utf8')); +} + +function rpc(ep, msg) { + return new Promise((resolve, reject) => { + const body = JSON.stringify(msg); + const req = http.request({ + host: '127.0.0.1', + port: ep.port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: 'Bearer ' + ep.token, + }, + }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return resolve(null); + try { resolve(JSON.parse(text)); } catch (e) { reject(e); } + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function writeMessage(obj) { + const body = Buffer.from(JSON.stringify(obj), 'utf8'); + process.stdout.write('Content-Length: ' + body.length + '\r\n\r\n'); + process.stdout.write(body); +} + +function fail(id, message) { + writeMessage({ jsonrpc: '2.0', id: id == null ? null : id, error: { code: -32000, message: message } }); +} + +function main() { + let buf = Buffer.alloc(0); + let ep = null; + try { ep = loadEndpoint(); } + catch (e) { + /* Endpoint is resolved per message so a late enable still works. */ + } + + function ensure() { + if (ep) return ep; + ep = loadEndpoint(); + return ep; + } + + function onMessage(msg) { + let endpoint; + try { endpoint = ensure(); } + catch (e) { + if (msg && msg.id != null) { + fail(msg.id, 'SoundVisualizer MCP is not running. Enable MCP on the Control card and keep the app open.'); + } + return; + } + rpc(endpoint, msg).then((res) => { + if (res && msg && msg.id != null) writeMessage(res); + }).catch(() => { + if (msg && msg.id != null) { + fail(msg.id, 'SoundVisualizer MCP is not reachable on 127.0.0.1. Enable MCP and keep the app open.'); + } + }); + } + + function pump() { + while (buf.length) { + const headerEnd = buf.indexOf('\r\n\r\n'); + if (headerEnd >= 0) { + const header = buf.slice(0, headerEnd).toString('utf8'); + const m = header.match(/Content-Length:\s*(\d+)/i); + if (!m) { buf = buf.slice(headerEnd + 4); continue; } + const len = Number(m[1]); + const start = headerEnd + 4; + if (buf.length < start + len) return; + const body = buf.slice(start, start + len).toString('utf8'); + buf = buf.slice(start + len); + try { onMessage(JSON.parse(body)); } catch (e) { /* ignore malformed */ } + continue; + } + const nl = buf.indexOf('\n'); + if (nl < 0) return; + const line = buf.slice(0, nl).toString('utf8').trim(); + buf = buf.slice(nl + 1); + if (!line || line[0] !== '{') continue; + try { onMessage(JSON.parse(line)); } catch (e) { /* ignore */ } + } + } + + process.stdin.on('data', (chunk) => { buf = Buffer.concat([buf, chunk]); pump(); }); +} + +if (require.main === module) main(); +else module.exports = { isLoopbackHost, endpointFrom }; + diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index cf66487..a38ccb6 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -109,6 +109,8 @@ contextBridge.exposeInMainWorld('api', { onPresets: (cb) => ipcRenderer.on('presets', (e, list) => cb(list)), // Değişiklik yayını ve toplu kaydın ilerlemesi (#574) onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), + presetsHead: () => ipcRenderer.invoke('presets:head'), + presetsSince: (gen) => ipcRenderer.invoke('presets:since', gen), onPresetsProgress: (cb) => ipcRenderer.on('presets-progress', (e, p) => cb(p)), /* MilkDrop kütüphanesi (#574): tarama bir özet ve kimlik döndürüyor, içe aktarım aynı kimlikle; dosya listeleri ana süreçte kalıyor. */ @@ -170,6 +172,8 @@ contextBridge.exposeInMainWorld('api', { nowPlayingSubscribe: (on) => ipcRenderer.send('nowplaying:subscribe', !!on), nowPlayingStatus: () => ipcRenderer.invoke('nowplaying:status'), nowPlayingCurrent: () => ipcRenderer.invoke('nowplaying:current'), + mcpStatus: () => ipcRenderer.invoke('mcp:status'), + mcpReportLive: (data) => ipcRenderer.send('mcp:live', data), onNowPlaying: (cb) => ipcRenderer.on('now-playing', (e, st) => cb(st)), onExternalConfig: (cb) => ipcRenderer.on('external-config', (e, c) => cb(c)), diff --git a/src/main/preload-visualizer.js b/src/main/preload-visualizer.js index ddf9b7f..ee37a65 100644 --- a/src/main/preload-visualizer.js +++ b/src/main/preload-visualizer.js @@ -2,6 +2,28 @@ const { contextBridge, ipcRenderer } = require('electron'); +/* Son çalan parça burada durur. Sayfa dinleyiciyi geç kurar; Electron + aradaki mesajı tutmaz. Bu dosya kum havuzundadır: electron dışında + require çalışmaz, o yüzden yardımcı buraya gömülüdür. */ +function createLateEvent() { + let listener = null; + let seen = false; + let value; + return { + push(next) { + seen = true; + value = next; + if (listener) listener(next); + }, + subscribe(cb) { + listener = typeof cb === 'function' ? cb : null; + if (seen && listener) listener(value); + }, + }; +} +const nowPlayingEvents = createLateEvent(); +ipcRenderer.on('now-playing', (_e, st) => nowPlayingEvents.push(st)); + contextBridge.exposeInMainWorld('SV_PLATFORM', { os: process.platform, isWindows: process.platform === 'win32', @@ -26,7 +48,8 @@ contextBridge.exposeInMainWorld('api', { /* Çalan parça çıpası. Konum HER KARE gelmez; kaynak ancak ara sıra güncelliyor, aradaki değeri pencere kendisi hesaplıyor. (bkz. src/shared/nowplaying.js) */ - onNowPlaying: (cb) => ipcRenderer.on('now-playing', (e, st) => cb(st)), + onNowPlaying: (cb) => nowPlayingEvents.subscribe(cb), + nowPlayingCurrent: () => ipcRenderer.invoke('nowplaying:current'), onNativeAudio: (cb) => ipcRenderer.on('native-audio', (e, frame) => cb(frame)), sendAudioMeter: (data) => ipcRenderer.send('audio-meter', data), sendMessage: (msg) => ipcRenderer.send('visualizer-message', msg), @@ -35,6 +58,8 @@ contextBridge.exposeInMainWorld('api', { onPresets: (cb) => ipcRenderer.on('presets', (e, list) => cb(list)), // Değişiklik yayını (#574): bütün liste yerine yalnız değişenler onPresetsDelta: (cb) => ipcRenderer.on('presets-delta', (e, d) => cb(d)), + presetsHead: () => ipcRenderer.invoke('presets:head'), + presetsSince: (gen) => ipcRenderer.invoke('presets:since', gen), /* MilkDrop doku paketi: preset kendi görselini ada göre istiyor (`sampler_worms` -> `worms.jpg`). Yalnızca yapılandırmada seçili klasörün içi okunuyor; kapsam denetimi ana süreçte. */ diff --git a/src/main/presets-store.js b/src/main/presets-store.js index 4a52329..496a3ad 100644 --- a/src/main/presets-store.js +++ b/src/main/presets-store.js @@ -15,8 +15,9 @@ kilitliyordu. Şimdi dosyalar bir kez okunuyor — açılışta arka planda (`warm`) — ve kayıt ile silme önbelleği yerinde güncelliyor. `list()` her çağrıda klasörün yalnız ADLARINA bakıyor (10 bin adda milisaniyeler): elle - eklenen ya da silinen dosya yine görünüyor. Elle DEĞİŞTİRİLEN bir dosya - uygulama yeniden açılınca görünüyor. */ + eklenen ya da silinen dosya yine görünüyor. İçeriği değişen dosya, izleyen + `syncDisk` çağrısında (dosya izleyicisi ya da MCP turu) yeniden okunuyor; + açık pencereler yeniden başlatılmadan listeyi alır. */ const fs = require('fs'); const path = require('path'); @@ -69,6 +70,23 @@ let warming = null; /* Arka plan okuması sürerken silinenler: okuma, silmeden ÖNCE aldığı ad listesinden bir önbellek kuruyor ve silineni geri getirebilirdi. */ const droppedWhileWarming = new Set(); +/* Arka plan okuması sürerken kaydedilenler: remember() önbellek henüz + yokken dönüyordu, warm() ise okumanın başındaki ad listesini yazıyordu. + Dosya diskte kalıyor, açık pencereler yeniden başlatılana kadar + görmüyordu. */ +const savedWhileWarming = new Map(); +/* Dosya damgası: mtime + boyut. Kendi kayıtlarımız yazınca damgayı işler; + dışarıdan değişen dosya ancak o zaman yeniden okunur. Bütün klasörü her + turda stat'lamak 10 bin presette ana süreci tutuyordu. */ +const stamps = new Map(); +/* `list()` yeni ya da silinen adı önbelleğe alır ama bunu kimse yayınlamazsa + diğer pencereler eski listede kalır. Değişiklik burada birikir; `syncDisk` + onu alıp temizler. */ +let queuedUpsert = []; +let queuedRemove = []; +let watchTimer = null; +let watcher = null; +const watchHints = new Set(); function names() { try { return fs.readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return []; } @@ -114,14 +132,21 @@ function warm() { } catch { /* okunamayan dosya atlanıyor */ } })); } + for (const pair of savedWhileWarming) map.set(pair[0], pair[1]); + savedWhileWarming.clear(); for (const n of droppedWhileWarming) map.delete(n); droppedWhileWarming.clear(); if (!byFile) { byFile = map; sorted = null; } + else { + for (const pair of map) if (!byFile.has(pair[0])) byFile.set(pair[0], pair[1]); + sorted = null; + } })(); return warming; } -// Klasörle eşitle: yeni adlar okunuyor, kaybolanlar düşüyor +// Klasörle eşitle: yeni adlar okunuyor, kaybolanlar düşüyor. +// Bulunan fark yayın kuyruğuna da yazılır (`syncDisk`). function rescan() { const now = names(); const seen = new Set(now); @@ -129,14 +154,112 @@ function rescan() { for (const n of now) { if (byFile.has(n)) continue; const p = readOne(n); - if (p) { byFile.set(n, p); changed = true; } + if (p) { + byFile.set(n, p); + noteStamp(path.join(dir(), n)); + queueUpsert(p); + changed = true; + } } for (const n of Array.from(byFile.keys())) { - if (!seen.has(n)) { byFile.delete(n); changed = true; } + if (!seen.has(n)) { + const old = byFile.get(n); + byFile.delete(n); + stamps.delete(n); + if (old && old.id) queueRemove(old.id); + changed = true; + } } if (changed) sorted = null; } +/* İzleyicinin verdiği adlar: içerik değişmişse yeniden oku. Adı olmayan + bir olay bütün klasörü stat'lamaz; yeni ve silinen adları `rescan` görür. */ +function refreshHints(hints) { + const folder = dir(); + for (const hint of hints || []) { + const n = path.basename(String(hint || '')); + if (!n || !n.endsWith('.json')) continue; + const file = path.join(folder, n); + let stamp = ''; + try { + const st = fs.statSync(file); + stamp = st.mtimeMs + ':' + st.size; + } catch { + continue; + } + if (!byFile.has(n)) continue; + if (stamps.get(n) === stamp) continue; + const p = readOne(n); + stamps.set(n, stamp); + if (!p) continue; + byFile.set(n, p); + sorted = null; + queueUpsert(p); + } +} + +function takeQueued() { + const upsert = []; + const seen = new Set(); + for (let i = queuedUpsert.length - 1; i >= 0; i--) { + const p = queuedUpsert[i]; + if (!p || !p.id || seen.has(p.id)) continue; + seen.add(p.id); + upsert.push(p); + } + upsert.reverse(); + const remove = []; + const dropped = new Set(); + for (const id of queuedRemove) { + if (seen.has(id) || dropped.has(id)) continue; + dropped.add(id); + remove.push(id); + } + queuedUpsert = []; + queuedRemove = []; + return { upsert, remove }; +} + +/* Açık pencerelere gidecek fark. `hints` izleyicinin gördüğü dosya adları. */ +function syncDisk(hints) { + ensure(); + rescan(); + if (hints && hints.length) refreshHints(hints); + return takeQueued(); +} + +function watch(onChange) { + if (watcher || typeof onChange !== 'function') return; + let folder; + try { folder = dir(); } catch { return; } + try { + watcher = fs.watch(folder, { persistent: false }, (event, filename) => { + if (filename) watchHints.add(String(filename)); + if (watchTimer) clearTimeout(watchTimer); + watchTimer = setTimeout(() => { + watchTimer = null; + const hints = Array.from(watchHints); + watchHints.clear(); + try { + const delta = syncDisk(hints); + if (delta.upsert.length || delta.remove.length) onChange(delta); + } catch { /* klasör kaybolduysa bir sonraki tur dener */ } + }, 80); + }); + } catch { watcher = null; } +} + +function closeWatch() { + if (watchTimer) clearTimeout(watchTimer); + watchTimer = null; + watchHints.clear(); + if (watcher) { + try { watcher.close(); } catch { /* zaten kapalı */ } + watcher = null; + } +} + function list() { ensure(); rescan(); @@ -181,12 +304,28 @@ function prepare(preset, stamp) { return { ok: true, p, file, json }; } +function noteStamp(file) { + try { + const st = fs.statSync(file); + stamps.set(path.basename(file), st.mtimeMs + ':' + st.size); + } catch { /* dosya yok: sonraki tur silme olarak görür */ } +} + function remember(file, p) { - if (!byFile) return; // henüz okunmadı: dosya ilk okumada gelecek - byFile.set(path.basename(file), p); + const name = path.basename(file); + noteStamp(file); + if (!byFile) { savedWhileWarming.set(name, p); return; } + byFile.set(name, p); sorted = null; } +function queueUpsert(p) { + if (p && p.id) queuedUpsert.push(p); +} +function queueRemove(id) { + if (id) queuedRemove.push(String(id)); +} + function save(preset) { const r = prepare(preset); if (!r.ok) return r; @@ -204,7 +343,9 @@ function remove(id) { if (!file) return { ok: false, error: 'BAD_ID' }; try { fs.unlinkSync(file); - if (byFile) { byFile.delete(path.basename(file)); sorted = null; } else if (warming) droppedWhileWarming.add(path.basename(file)); + const base = path.basename(file); + if (byFile) { byFile.delete(base); sorted = null; } + else if (warming) { droppedWhileWarming.add(base); savedWhileWarming.delete(base); } return { ok: true }; } catch (e) { return { ok: false, error: e.message }; @@ -250,11 +391,19 @@ async function saveManyAsync(presets, onProgress) { // Test kancası: depoyu başka bir klasöre yönelt ve önbelleği boşalt function setDir(d) { + closeWatch(); root = d || null; byFile = null; sorted = null; warming = null; droppedWhileWarming.clear(); + savedWhileWarming.clear(); + stamps.clear(); + queuedUpsert = []; + queuedRemove = []; } -module.exports = { list, get, save, saveMany, saveManyAsync, remove, dir, warm, compare, setDir }; +module.exports = { + list, get, save, saveMany, saveManyAsync, remove, dir, warm, compare, setDir, + syncDisk, watch, +}; diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 092fdd2..988c5d7 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -1121,6 +1121,13 @@ encoder: 'gpu', // 'gpu' (NVIDIA NVENC, hızlı) | 'cpu' (libx264, en uyumlu) speed: 'balanced', // 'fast' | 'balanced' | 'quality' — hız/kalite dengesi (preset) }, + + /* MCP control card. Master stays off. When enabled, the mode is read until the user picks another. */ + mcp: { + enabled: false, + mode: 'read', + port: 38471, + }, }; // Ek görsel nesne (partikül emitör) varsayılanları. diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 3b42871..1044efe 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2,12 +2,12 @@ /* Lightweight UI localization. English is the fallback; Turkish is used only when the operating-system/browser locale starts with "tr". */ (function () { - const detected = (navigator.languages && navigator.languages[0]) || navigator.language || 'en'; + const detected = (typeof navigator !== 'undefined' && ((navigator.languages && navigator.languages[0]) || navigator.language)) || 'en'; /* Dil tercihi. Yayın sunucusu tarafından servis edilen sayfalarda (OBS katmanı ve mobil kumanda) uygulamanın dili sayfaya window.__SV_LOCALE ile enjekte edilir; telefonun kendi dili değil, uygulamanın dili geçerlidir. */ const injected = typeof window !== 'undefined' ? window.__SV_LOCALE : null; - const preference = injected || localStorage.getItem('sv-language') || 'auto'; + const preference = injected || (typeof localStorage !== 'undefined' ? localStorage.getItem('sv-language') : null) || 'auto'; const locale = preference === 'tr' || preference === 'en' ? preference : (/^tr(?:-|$)/i.test(detected) ? 'tr' : 'en'); @@ -2696,6 +2696,8 @@ 'Yazı': 'Typography', 'Yazı Tipi': 'Font', 'Hizalama': 'Alignment', + 'Yazı Saydamlığı': 'Text Opacity', + 'Çubuk Kalınlığı': 'Bar Thickness', 'Kontur': 'Outline', 'Gölge': 'Shadow', 'Senkron Kayması': 'Sync Offset', @@ -3028,8 +3030,45 @@ 'Renk Modu': 'Color Mode', 'Sabit Renk': 'Fixed Color', 'Renk Teması': 'Color Theme', + // MCP control card + 'MCP': 'MCP', + 'Kurulum': 'Setup', + 'Efekt': 'Effect', + 'Var olanı uygula': 'Apply what already exists', + 'Oluştur ve düzenle': 'Create and edit', + 'Dışa aktarma': 'Export', + 'Karartma': 'Blackout', + 'MCP kapalı.': 'MCP is off.', + 'stdio dinleniyor.': 'Listening on stdio.', + 'MCP açılıyor…': 'MCP is starting…', + 'MCP kurulumu': 'MCP setup', + 'Hepsi aynı stdio sunucusuna bağlanır. Ağ portu yalnız 127.0.0.1. Ollama ayrı bir protokol değildir.': 'All of them connect to the same stdio server. The socket is 127.0.0.1 only. Ollama is not a separate protocol.', + 'Ajan sahneleri, katmanları, efektleri, presetleri, çıkışı ve karartmayı bu karttaki izinlerle sürer. Okuma (BPM, durum, önizleme) anahtar açılınca serbesttir. Yazma grupları varsayılan kapalıdır.': 'An agent drives scenes, layers, effects, presets, output, and blackout with the switches on this card. Reads (BPM, state, preview) are available when the master switch is on. Write groups stay off by default.', + 'Ajan bu karttaki kiple sürer. Kapalı başlar; açılınca okuma. Her şey kipi tek tık ve varsayılan değil.': 'An agent drives the app through the mode on this card. It starts off; when on, the mode is read. Everything is one click and is not the default.', + 'MCP açıkken bu uygulamayı açık tutun.': 'Keep this app open while MCP is enabled.', + 'Windows’ta %APPDATA%\\Claude\\claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.': 'On Windows, add the mcpServers block to %APPDATA%\\Claude\\claude_desktop_config.json.', + 'macOS’ta ~/Library/Application Support/Claude/claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.': 'On macOS, add the mcpServers block to ~/Library/Application Support/Claude/claude_desktop_config.json.', + 'Linux’ta ~/.config/Claude/claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.': 'On Linux, add the mcpServers block to ~/.config/Claude/claude_desktop_config.json.', + 'Claude Desktop’u tamamen kapatıp yeniden açın.': 'Quit Claude Desktop completely and open it again.', + 'Tablo %USERPROFILE%\\.codex\\config.toml dosyasına eklenir. codex mcp add de aynı yere yazar.': 'Add the table to %USERPROFILE%\\.codex\\config.toml. codex mcp add writes to that same file.', + 'Tablo ~/.codex/config.toml dosyasına eklenir. codex mcp add de aynı yere yazar.': 'Add the table to ~/.codex/config.toml. codex mcp add writes to that same file.', + 'Yeni bir Codex oturumu açın.': 'Open a new Codex session.', + 'Proje için .cursor/mcp.json, genel için %USERPROFILE%\\.cursor\\mcp.json kullanın.': 'Use .cursor/mcp.json for this project, or %USERPROFILE%\\.cursor\\mcp.json for every project.', + 'Proje için .cursor/mcp.json, genel için ~/.cursor/mcp.json kullanın.': 'Use .cursor/mcp.json for this project, or ~/.cursor/mcp.json for every project.', + 'Cursor MCP listesini yenileyin.': 'Reload the Cursor MCP list.', + 'Grok için yayınlanmış tek bir MCP ayar dosyası yok.': 'Grok does not publish one MCP settings file path.', + 'Stdio kabul eden istemcide aşağıdaki komutu kullanın.': 'If the client accepts stdio MCP, use the command below.', + 'mcpServers JSON’unu o istemcinin MCP listesine yapıştırın.': 'Paste the mcpServers JSON into that client\'s MCP list.', + 'Grok Bot yerel bir mcp.json yolu yayınlamıyor.': 'Grok Bot does not publish a local mcp.json path.', + 'Aynı stdio komutunu MCP sunucusu olarak ekleyin.': 'Add the same stdio command as an MCP server.', + 'Aşağıdaki mcpServers bloğu geçerlidir. Ayrı bir protokol yok.': 'The mcpServers block below is the one to use. There is no separate protocol.', + 'Ollama ayrı bir MCP protokolü değildir. Ücretsiz yerel model, aynı MCP sunucusuna bağlanan bir istemcidir.': 'Ollama is not a separate MCP protocol. The free local-model path is a client that connects to this same MCP server.', + 'Ollama’yı kurun, bir model çekin ve yerelde ollama serve çalışsın.': 'Install Ollama, pull a model, and run ollama serve locally.', + 'MCP konuşan istemcide modeli Ollama’ya yöneltin ve bu stdio sunucusunu ekleyin.': 'In an MCP-capable client, point the model at Ollama and add this stdio server.', + 'Aşağıdaki blok bu sunucudur.': 'The block below is this server.', }; + function normalize(value) { return String(value).replace(/\s+/g, ' ').trim(); } const EN_NORMALIZED = Object.fromEntries( Object.entries(EN).map(([key, translated]) => [normalize(key), translated]) @@ -3201,8 +3240,706 @@ for (const child of node.childNodes) translateNode(child); } + + const MCP_I18N = { + "mcp.mode.read.word": { + "tr": "okuma", + "en": "read" + }, + "mcp.mode.apply.word": { + "tr": "uygula", + "en": "apply" + }, + "mcp.mode.write.word": { + "tr": "yaz", + "en": "write" + }, + "mcp.mode.full.word": { + "tr": "tam", + "en": "full" + }, + "mcp.mode.everything.word": { + "tr": "her şey", + "en": "everything" + }, + "mcp.mode.read.label": { + "tr": "Okuma", + "en": "Read" + }, + "mcp.mode.apply.label": { + "tr": "Uygula", + "en": "Apply" + }, + "mcp.mode.write.label": { + "tr": "Yazma", + "en": "Write" + }, + "mcp.mode.full.label": { + "tr": "Tam", + "en": "Full" + }, + "mcp.mode.everything.label": { + "tr": "Her şey", + "en": "Everything" + }, + "mcp.mode.read.hint": { + "tr": "Her şeyi görür (preset, efekt, ekran, durum) ama hiçbir şeyi değiştirmez.", + "en": "Sees everything (preset, effects, screens, state) and changes nothing." + }, + "mcp.mode.apply.hint": { + "tr": "Var olanı da uygular; oluşturamaz ve düzenleyemez.", + "en": "Can also apply what already exists, and cannot create or edit." + }, + "mcp.mode.write.hint": { + "tr": "Oluşturur ve düzenler (Otomatik VJ dahil); ışık, eşleme, pencere, zaman çizelgesi sarma/durdurma, Spout ve yayın ağı/jetonu kapalı kalır.", + "en": "Can create and edit, including Auto VJ; lighting, mapping, windows, timeline seek/stop, Spout, and stream LAN/token stay off." + }, + "mcp.mode.full.hint": { + "tr": "Işık, eşleme, pencere, zaman çizelgesi, Spout ve yayın dahil normal gösteri kontrolleri; güncelleme kurma ve ses onarımı yok.", + "en": "Normal show controls, including those live surfaces; no app-update install and no audio repair." + }, + "mcp.mode.everything.hint": { + "tr": "Bunların tümü, artı güncelleme, ses onarımı ve jeton döndürme.", + "en": "All of that, plus updates, audio repair, and token rotation." + }, + "mcp.mode.read.unlocks": { + "tr": "Her şeyi görür (preset, efekt, ekran, durum) ama hiçbir şeyi değiştirmez.", + "en": "Sees everything (preset, effects, screens, state) and changes nothing." + }, + "mcp.mode.apply.unlocks": { + "tr": "Var olanı da uygular; oluşturamaz ve düzenleyemez.", + "en": "Can also apply what already exists, and cannot create or edit." + }, + "mcp.mode.write.unlocks": { + "tr": "Oluşturur ve düzenler (Otomatik VJ dahil); ışık, eşleme, pencere, zaman çizelgesi sarma/durdurma, Spout ve yayın ağı/jetonu kapalı kalır.", + "en": "Can create and edit, including Auto VJ; lighting, mapping, windows, timeline seek/stop, Spout, and stream LAN/token stay off." + }, + "mcp.mode.full.unlocks": { + "tr": "Işık, eşleme, pencere, zaman çizelgesi, Spout ve yayın dahil normal gösteri kontrolleri; güncelleme kurma ve ses onarımı yok.", + "en": "Normal show controls, including those live surfaces; no app-update install and no audio repair." + }, + "mcp.mode.everything.unlocks": { + "tr": "Bunların tümü, artı güncelleme, ses onarımı ve jeton döndürme.", + "en": "All of that, plus updates, audio repair, and token rotation." + }, + "mcp.err.mode": { + "tr": "Bu iş için MCP modu «{mode}» gerekir. Modu sen değiştirme. Yönetici panelini tıklama. Kullanıcıya söyle ve dur.", + "en": "This needs MCP mode «{mode}». Do not change the mode yourself. Do not click the admin UI. Tell the user and stop." + }, + "mcp.err.disabled": { + "tr": "MCP kapalı. Bu iş için MCP modu «{mode}» gerekir. Modu sen değiştirme. Yönetici panelini tıklama. Kullanıcıya söyle ve dur.", + "en": "MCP is off. This needs MCP mode «{mode}». Do not change the mode yourself. Do not click the admin UI. Tell the user and stop." + }, + "mcp.err.mcp": { + "tr": "MCP izinlerini sen değiştiremezsin. Modu sen değiştirme. Yönetici panelini tıklama. Kullanıcıya söyle ve dur.", + "en": "You cannot change MCP permissions. Do not change the mode yourself. Do not click the admin UI. Tell the user and stop." + }, + "mcp.err.token": { + "tr": "Jeton döndürmek Her şey kipini ister. sv_rotate_stream_token kullanın.", + "en": "Rotating a token needs Everything. Use sv_rotate_stream_token." + }, + "mcp.err.unavailable": { + "tr": "Bu işlem bu süreçte yok.", + "en": "This action is not available in this process." + }, + "mcp.err.repairManual": { + "tr": "Ses onarımı uygulamada elle bir adım ister. MCP sistem penceresi açmaz.", + "en": "Audio repair needs a manual step in the app. MCP will not open a system dialog." + }, + "mcp.perm.read": { + "tr": "İzin: MCP açıkken okuma.", + "en": "Permission: read while MCP is on." + }, + "mcp.perm.routed": { + "tr": "İzin: yola göre, kip merdivenine bağlı.", + "en": "Permission: depends on the path and the mode ladder." + }, + "mcp.perm.needs": { + "tr": "İzin: {mode} veya üstü.", + "en": "Permission: {mode} or higher." + }, + "mcp.perm.handsOff": { + "tr": "Modu sen değiştirme. Yönetici panelini tıklama. Gerekli kipin adını kullanıcıya söyle ve dur.", + "en": "Do not change the mode yourself. Do not click the admin UI. Tell the user the minimum mode name and stop." + }, + "mcp.card.note": { + "tr": "Beş kip birikir. Anahtar kapalıyken ajan hiçbir şey yapamaz. Açılınca varsayılan kip okumadır. Her şey tek tık ve varsayılan seçili değildir.", + "en": "Five modes stack. While the master switch is off the agent can do nothing. When it is on, the default mode is read. Everything is one click and is not selected by default." + }, + "mcp.card.off": { + "tr": "MCP kapalı.", + "en": "MCP is off." + }, + "mcp.card.listening": { + "tr": "stdio dinleniyor.", + "en": "Listening on stdio." + }, + "mcp.card.starting": { + "tr": "MCP açılıyor…", + "en": "MCP is starting…" + }, + "mcp.card.setup": { + "tr": "Kurulum", + "en": "Setup" + }, + "mcp.card.copy": { + "tr": "Kopyala", + "en": "Copy" + }, + "mcp.card.copied": { + "tr": "Kopyalandı.", + "en": "Copied." + }, + "mcp.card.copyFail": { + "tr": "Kopyalanamadı.", + "en": "Could not copy." + }, + "mcp.card.close": { + "tr": "Kapat", + "en": "Close" + }, + "mcp.card.master": { + "tr": "MCP", + "en": "MCP" + }, + "mcp.setup.note": { + "tr": "Hepsi aynı stdio sunucusuna bağlanır. Ağ portu yalnız 127.0.0.1. Ollama ayrı bir protokol değildir.", + "en": "They all connect to the same stdio server. The network port is only 127.0.0.1. Ollama is not a separate protocol." + }, + "mcp.setup.title": { + "tr": "MCP kurulumu", + "en": "MCP setup" + }, + "mcp.setup.keepOpen": { + "tr": "MCP açıkken bu uygulamayı açık tutun.", + "en": "Keep this app open while MCP is enabled." + }, + "mcp.setup.claude.file": { + "tr": "Windows’ta %APPDATA%\\Claude\\claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.", + "en": "Add the mcpServers block to %APPDATA%\\Claude\\claude_desktop_config.json on Windows." + }, + "mcp.setup.claude.restart": { + "tr": "Claude Desktop’u tamamen kapatıp yeniden açın.", + "en": "Quit Claude Desktop completely and open it again." + }, + "mcp.setup.codex.file": { + "tr": "Tablo %USERPROFILE%\\.codex\\config.toml dosyasına eklenir. codex mcp add de aynı yere yazar.", + "en": "The table goes in %USERPROFILE%\\.codex\\config.toml. codex mcp add writes there too." + }, + "mcp.setup.codex.session": { + "tr": "Yeni bir Codex oturumu açın.", + "en": "Open a new Codex session." + }, + "mcp.setup.cursor.file": { + "tr": "Proje için .cursor/mcp.json, genel için %USERPROFILE%\\.cursor\\mcp.json kullanın.", + "en": "Use .cursor/mcp.json for this project, or %USERPROFILE%\\.cursor\\mcp.json for every project." + }, + "mcp.setup.cursor.reload": { + "tr": "Cursor MCP listesini yenileyin.", + "en": "Reload the Cursor MCP list." + }, + "mcp.setup.grok.none": { + "tr": "Grok için yayınlanmış tek bir MCP ayar dosyası yok.", + "en": "Grok does not publish one MCP settings file path." + }, + "mcp.setup.grok.stdio": { + "tr": "Stdio kabul eden istemcide aşağıdaki komutu kullanın.", + "en": "If the client accepts stdio MCP, use the command below." + }, + "mcp.setup.grok.paste": { + "tr": "mcpServers JSON’unu o istemcinin MCP listesine yapıştırın.", + "en": "Paste the mcpServers JSON into that client's MCP list." + }, + "mcp.setup.grokbot.none": { + "tr": "Grok Bot yerel bir mcp.json yolu yayınlamıyor.", + "en": "Grok Bot does not publish a local mcp.json path." + }, + "mcp.setup.grokbot.add": { + "tr": "Aynı stdio komutunu MCP sunucusu olarak ekleyin.", + "en": "Add the same stdio command as an MCP server." + }, + "mcp.setup.grokbot.block": { + "tr": "Aşağıdaki mcpServers bloğu geçerlidir. Ayrı bir protokol yok.", + "en": "The mcpServers block below is the one to use. There is no separate protocol." + }, + "mcp.setup.ollama.same": { + "tr": "Ollama ayrı bir MCP protokolü değildir. Ücretsiz yerel model, aynı MCP sunucusuna bağlanan bir istemcidir.", + "en": "Ollama is not a separate MCP protocol. The free local-model path is a client that connects to this same MCP server." + }, + "mcp.setup.ollama.install": { + "tr": "Ollama’yı kurun, bir model çekin ve yerelde ollama serve çalışsın.", + "en": "Install Ollama, pull a model, and run ollama serve locally." + }, + "mcp.setup.ollama.point": { + "tr": "MCP konuşan istemcide modeli Ollama’ya yöneltin ve bu stdio sunucusunu ekleyin.", + "en": "In an MCP-capable client, point the model at Ollama and add this stdio server." + }, + "mcp.setup.ollama.block": { + "tr": "Aşağıdaki blok bu sunucudur.", + "en": "The block below is this server." + }, + "mcp.tool.sv_get_state": { + "tr": "Gösteri durumunu okur: sahne, katmanlar, efektler, karartma, izinler ve ses. Salt okunur.", + "en": "Read show state: scene, layers, effects, blackout, permissions, and audio. Read-only." + }, + "mcp.tool.sv_get_visual_state": { + "tr": "Canlı görsel durumu okur: katman konumları, ayarlar, katman efektleri, genel efektler, etkin sahne. Salt okunur.", + "en": "Read live visual state: layer positions, settings, per-layer effects, global effects, active scene. Read-only." + }, + "mcp.tool.sv_get_preview": { + "tr": "Görsel durumu ve açık bir pencere varsa canlı tuvalin küçük JPEG görüntüsünü okur. Salt okunur. Düzenlemeler arasında kullanın.", + "en": "Read visual state plus a small JPEG of the live canvas when a window is open. Read-only. Use it between edits." + }, + "mcp.tool.sv_get_audio": { + "tr": "BPM ile seviye, bas, orta ve tiz değerlerini okur. Salt okunur.", + "en": "Read BPM and level/bass/mid/treble. Read-only." + }, + "mcp.tool.sv_get_now_playing": { + "tr": "Çalan parçayı okur. Salt okunur.", + "en": "Read the current track. Read-only." + }, + "mcp.tool.sv_list_scenes": { + "tr": "Kayıtlı sahneleri listeler. Salt okunur.", + "en": "List saved scenes. Read-only." + }, + "mcp.tool.sv_get_scene": { + "tr": "Kayıtlı bir sahneyi okur. Salt okunur.", + "en": "Read one saved scene. Read-only." + }, + "mcp.tool.sv_list_layers": { + "tr": "Katmanları konum, ayar ve efektleriyle listeler. Salt okunur.", + "en": "List layers with position, settings, and effects. Read-only." + }, + "mcp.tool.sv_get_layer": { + "tr": "Bir katmanı okur. Salt okunur.", + "en": "Read one layer. Read-only." + }, + "mcp.tool.sv_list_effects": { + "tr": "Genel ve katman efektlerini ve hazır tür kataloğunu listeler. Salt okunur.", + "en": "List global and per-layer effects plus the built-in type catalog. Read-only." + }, + "mcp.tool.sv_list_presets": { + "tr": "Kitaplık presetlerini ve kullanıcı renk presetlerini listeler. Salt okunur.", + "en": "List library presets and user color presets. Read-only." + }, + "mcp.tool.sv_list_displays": { + "tr": "Ekranları listeler. Salt okunur.", + "en": "List displays. Read-only." + }, + "mcp.tool.sv_get_output_status": { + "tr": "Görselleştirici ve yayın durumunu okur. Salt okunur. Yayın jetonları dahil değildir.", + "en": "Read visualizer and stream status. Read-only. Stream tokens are not included." + }, + "mcp.tool.sv_get_config": { + "tr": "Yapılandırmayı veya noktalı bir yolu okur. Yayın jetonları gizlenir. Salt okunur.", + "en": "Read config or one dotted path. Stream tokens are redacted. Read-only." + }, + "mcp.tool.sv_list_permissions": { + "tr": "Geçerli MCP kipini ve bir aracın en düşük kipini bildirir. Modu sen değiştirme. Yönetici panelini tıklama. Kullanıcıya söyle ve dur.", + "en": "Report the current MCP mode and the minimum mode a tool needs. Do not change the mode yourself. Do not click the admin UI. Tell the user and stop." + }, + "mcp.tool.sv_get_timeline": { + "tr": "Zaman çizelgesi verisini okur. Salt okunur.", + "en": "Read timeline data. Read-only." + }, + "mcp.tool.sv_get_clipdeck": { + "tr": "Klip destesi yuvalarını okur. Salt okunur.", + "en": "Read clip deck slots. Read-only." + }, + "mcp.tool.sv_get_autovj": { + "tr": "Otomatik VJ ayarlarını okur. Salt okunur.", + "en": "Read Auto VJ settings. Read-only." + }, + "mcp.tool.sv_apply_scene": { + "tr": "Var olan bir sahneye kimlik veya adla geçer. Sahne içeriğini oluşturmaz veya düzenlemez.", + "en": "Switch to an existing scene by id or name. Does not create or edit scene contents." + }, + "mcp.tool.sv_set_visualizer_type": { + "tr": "Klasik görselleştiriciyi var olan bir kip kimliğine alır.", + "en": "Switch the classic visualizer to an existing mode id." + }, + "mcp.tool.sv_set_background_type": { + "tr": "Arkaplanı var olan bir kip kimliğine alır.", + "en": "Switch the background to an existing mode id." + }, + "mcp.tool.sv_set_layer_enabled": { + "tr": "Var olan bir katmanı gösterir veya gizler ve katman yığınını açar. Katman içeriğini değiştirmez.", + "en": "Show or hide an existing layer and turn the layer stack on. Does not change layer contents." + }, + "mcp.tool.sv_set_crossfade": { + "tr": "Var olan A/B geçiş sürgüsünü oynatır (0 ile 1 arası).", + "en": "Move the existing A/B crossfader (0..1)." + }, + "mcp.tool.sv_apply_template": { + "tr": "Var olan yerleşik bir şablonu kimliğiyle uygular.", + "en": "Apply an existing built-in template by id." + }, + "mcp.tool.sv_timeline_transport": { + "tr": "Var olan zaman çizelgesini yönetici taşımasıyla oynatır, duraklatır, durdurur veya sarar.", + "en": "Play, pause, stop, or seek the existing timeline via the admin transport." + }, + "mcp.tool.sv_trigger_clip": { + "tr": "Var olan bir klip destesi yuvasını ateşler. Izgarayı düzenlemez.", + "en": "Fire an existing clip-deck slot. Does not edit the grid." + }, + "mcp.tool.sv_stop_clips": { + "tr": "Klip destesindeki bütün çalan yuvaları durdurur. Izgarayı düzenlemez.", + "en": "Stop every playing clip-deck slot. Does not edit the grid." + }, + "mcp.tool.sv_set_autovj": { + "tr": "Otomatik VJ'yi açar veya kapatır ve var olan sahne, kip veya paletlerde nasıl ilerleyeceğini seçer.", + "en": "Turn Auto VJ on or off and choose how it walks existing scenes, modes, or palettes." + }, + "mcp.tool.sv_create_scene": { + "tr": "Geçerli görsellerden bir sahne oluşturur. Yazarlık.", + "en": "Create a scene from the current visuals. Authoring." + }, + "mcp.tool.sv_update_scene": { + "tr": "Var olan bir sahnenin üzerine geçerli görselleri yazar. Yazarlık.", + "en": "Overwrite an existing scene with the current visuals. Authoring." + }, + "mcp.tool.sv_rename_scene": { + "tr": "Bir sahneyi yeniden adlandırır. Yazarlık.", + "en": "Rename a scene. Authoring." + }, + "mcp.tool.sv_delete_scene": { + "tr": "Bir sahneyi siler. Yazarlık.", + "en": "Delete a scene. Authoring." + }, + "mcp.tool.sv_add_layer": { + "tr": "Bir katman ekler ve katman yığınını açar. Yazarlık.", + "en": "Add a layer and turn the layer stack on. Authoring." + }, + "mcp.tool.sv_update_layer": { + "tr": "Katmanın adını, türünü, opaklığını, karışımını, dönüşümünü veya ayarlarını değiştirir. Efekt eklemez. Yazarlık.", + "en": "Change layer name, type, opacity, blend, transform, or settings. Does not add effects. Authoring." + }, + "mcp.tool.sv_set_layer_position": { + "tr": "Katmanın x, y, ölçek ve dönüşünü ayarlar. Yazarlık.", + "en": "Set layer x, y, scale, and rotate. Authoring." + }, + "mcp.tool.sv_set_layer_settings": { + "tr": "Var olan bir katmanın ayarlarını birleştirir. Yazarlık.", + "en": "Merge settings on an existing layer. Authoring." + }, + "mcp.tool.sv_remove_layer": { + "tr": "Bir katmanı kaldırır. Yazarlık.", + "en": "Remove a layer. Authoring." + }, + "mcp.tool.sv_reorder_layers": { + "tr": "Katmanları kimlik listesine göre sıralar. Yazarlık.", + "en": "Reorder layers by id list. Authoring." + }, + "mcp.tool.sv_set_text": { + "tr": "Yazı katmanını düzenler. Söz veya çalan parça kaynağı da buna dahildir. Yazarlık.", + "en": "Edit the text overlay, including a lyrics or now-playing source. Authoring." + }, + "mcp.tool.sv_set_logo": { + "tr": "Logo ayarlarını düzenler. Yazarlık.", + "en": "Edit logo settings. Authoring." + }, + "mcp.tool.sv_set_media": { + "tr": "Medya katmanı ayarlarını düzenler. Yazarlık.", + "en": "Edit media-layer settings. Authoring." + }, + "mcp.tool.sv_set_geometry": { + "tr": "Geometri ayarlarını düzenler. Yazarlık.", + "en": "Edit geometry settings. Authoring." + }, + "mcp.tool.sv_set_effect_enabled": { + "tr": "Genel zincirde zaten duran bir efekti açar veya kapatır.", + "en": "Enable or disable an effect already on the global chain." + }, + "mcp.tool.sv_set_effect_param": { + "tr": "Zaten var olan genel bir efektin parametrelerini değiştirir.", + "en": "Change parameters of a global effect that already exists." + }, + "mcp.tool.sv_set_layer_effect_enabled": { + "tr": "Bir katmanda zaten duran bir efekti açar veya kapatır.", + "en": "Enable or disable an effect already on a layer." + }, + "mcp.tool.sv_set_layer_effect_param": { + "tr": "Bir katmanda zaten duran bir efektin parametrelerini değiştirir.", + "en": "Change parameters of an effect already on a layer." + }, + "mcp.tool.sv_set_modulation_enabled": { + "tr": "Yolları düzenlemeden modülasyon matrisini açar veya kapatır.", + "en": "Turn the modulation matrix on or off without editing routes." + }, + "mcp.tool.sv_set_macro": { + "tr": "Var olan bir makro sürgüsünü ayarlar.", + "en": "Set an existing macro fader." + }, + "mcp.tool.sv_add_effect": { + "tr": "Genel zincire bir efekt ekler. Yazarlık.", + "en": "Add an effect to the global chain. Authoring." + }, + "mcp.tool.sv_remove_effect": { + "tr": "Genel bir efekti kaldırır. Yazarlık.", + "en": "Remove a global effect. Authoring." + }, + "mcp.tool.sv_add_layer_effect": { + "tr": "Belirli bir katmana efekt ekler. Yazarlık. Devam etmek için ardından durumu okuyun.", + "en": "Add an effect onto a specific layer. Authoring. Read state afterwards to continue." + }, + "mcp.tool.sv_remove_layer_effect": { + "tr": "Bir katmandan efekt kaldırır. Yazarlık.", + "en": "Remove an effect from a layer. Authoring." + }, + "mcp.tool.sv_add_modulation_route": { + "tr": "Bir modülasyon yolu ekler. Yazarlık.", + "en": "Add a modulation route. Authoring." + }, + "mcp.tool.sv_remove_modulation_route": { + "tr": "Bir modülasyon yolunu kaldırır. Yazarlık.", + "en": "Remove a modulation route. Authoring." + }, + "mcp.tool.sv_load_preset": { + "tr": "Var olan bir kitaplık presetini MilkDrop içine yükler. Yeni bir preset dosyası yazmaz.", + "en": "Load an existing library preset into MilkDrop. Does not write a new preset file." + }, + "mcp.tool.sv_apply_color_preset": { + "tr": "Var olan bir kullanıcı veya yerleşik renk presetini uygular. Yenisini oluşturmaz.", + "en": "Apply an existing user or built-in color preset. Does not create one." + }, + "mcp.tool.sv_set_milkdrop_cycle": { + "tr": "Var olan MilkDrop kitaplığının nasıl ilerleyeceğini değiştirir. Preset kaynağı yazmaz.", + "en": "Change how the existing MilkDrop library advances. Does not write preset source." + }, + "mcp.tool.sv_save_preset": { + "tr": "Uygulama preset deposuna bir preset dosyası yazar. Yazarlık.", + "en": "Write a preset file in the app preset store. Authoring." + }, + "mcp.tool.sv_delete_preset": { + "tr": "Bir preset dosyasını siler. Yazarlık.", + "en": "Delete a preset file. Authoring." + }, + "mcp.tool.sv_set_milkdrop_source": { + "tr": "MilkDrop kaynağını canlı yapılandırmaya yazar. Yazarlık. Var olan bir kimliği yüklemek sv_load_preset aracıdır.", + "en": "Write MilkDrop source into the live config. Authoring. Loading an existing id is sv_load_preset." + }, + "mcp.tool.sv_create_color_preset": { + "tr": "Bir kullanıcı renk preseti kaydeder. Yazarlık.", + "en": "Save a user color preset. Authoring." + }, + "mcp.tool.sv_delete_color_preset": { + "tr": "Bir kullanıcı renk presetini siler. Yazarlık.", + "en": "Delete a user color preset. Authoring." + }, + "mcp.tool.sv_open_output": { + "tr": "Görselleştiriciyi seçilen ekranlarda açar.", + "en": "Open the visualizer on the selected displays." + }, + "mcp.tool.sv_close_output": { + "tr": "Görselleştirici pencerelerini kapatır.", + "en": "Close visualizer windows." + }, + "mcp.tool.sv_set_displays": { + "tr": "Ekranları açmadan seçer.", + "en": "Choose displays without opening them." + }, + "mcp.tool.sv_set_stream": { + "tr": "OBS ve web yayın ayarlarını değiştirir. Jeton alanları burada yok sayılır.", + "en": "Change OBS/web stream settings. Token fields are ignored here." + }, + "mcp.tool.sv_set_texture_share": { + "tr": "Spout veya Syphon ayarlarını değiştirir.", + "en": "Change Spout/Syphon settings." + }, + "mcp.tool.sv_set_aspect": { + "tr": "En-boy ayarlarını değiştirir.", + "en": "Change aspect settings." + }, + "mcp.tool.sv_set_floating": { + "tr": "Yüzen pencere tercihlerini değiştirir.", + "en": "Change floating window preferences." + }, + "mcp.tool.sv_set_floating_open": { + "tr": "Yüzen pencereyi (PiP) açar veya kapatır. Ekran menüsündeki anahtarla aynı pencere. Saydamlık ve tıklama geçişini değiştirmez.", + "en": "Open or close the floating PiP window. Same window as the display-menu switch. Does not change opacity or click-through." + }, + "mcp.tool.sv_set_power": { + "tr": "Kare hızı sınırı ve çizim ölçeğini değiştirir.", + "en": "Change fps cap and render scale." + }, + "mcp.tool.sv_set_lighting": { + "tr": "Windows Dinamik Aydınlatma ayarlarını değiştirir.", + "en": "Change Windows Dynamic Lighting settings." + }, + "mcp.tool.sv_set_openrgb": { + "tr": "OpenRGB ayarlarını değiştirir.", + "en": "Change OpenRGB settings." + }, + "mcp.tool.sv_set_artnet": { + "tr": "Art-Net ve DMX ayarlarını değiştirir.", + "en": "Change Art-Net/DMX settings." + }, + "mcp.tool.sv_set_window_mode": { + "tr": "Saydam arkaplanı ve görev çubuğunu örtme ayarını değiştirir.", + "en": "Set transparent background and taskbar cover." + }, + "mcp.tool.sv_start_export": { + "tr": "Çevrimdışı video dışa aktarmayı başlatır. audioPath ve outputPath gerekir.", + "en": "Start an offline video export. Requires audioPath and outputPath." + }, + "mcp.tool.sv_cancel_export": { + "tr": "Süren çevrimdışı dışa aktarmayı iptal eder.", + "en": "Cancel the running offline export." + }, + "mcp.tool.sv_export_json": { + "tr": "Sahneleri veya tüm yapılandırma JSON dosyasını verilen yola yazar. Kayıt penceresi açmaz.", + "en": "Write scenes or full config JSON to an explicit path. No save dialog." + }, + "mcp.tool.sv_save_snapshot": { + "tr": "Canlı tuvali bir dosyaya yakalar. Kaydetmeden önizleme için sv_get_preview kullanılır.", + "en": "Capture the live canvas to a file. Reading a preview without saving is sv_get_preview." + }, + "mcp.tool.sv_record_start": { + "tr": "Yönetici panelindeki canlı kaydı başlatır.", + "en": "Start the admin live recorder." + }, + "mcp.tool.sv_record_stop": { + "tr": "Canlı kaydı durdurur. Uygulama ardından Kayıt kartındaki gibi kayıt yerini sorar.", + "en": "Stop the live recorder. The app then asks where to save, same as the Record card." + }, + "mcp.tool.sv_set_blackout": { + "tr": "Gösteriyi karartır veya geri açar. state on, off veya toggle olabilir. Sahne verisi durur.", + "en": "Black out or restore the show. state is on, off, or toggle. Scene data stays intact." + }, + "mcp.tool.sv_set_blackout_transition": { + "tr": "Karartma geçişinin türünü ve süresini ayarlar.", + "en": "Set the blackout transition type and duration." + }, + "mcp.tool.sv_patch_config": { + "tr": "Başka bir yapılandırma yolunu yazar. İzin yola bağlıdır. MCP izinlerini değiştiremez.", + "en": "Set any other config path. Permission follows the path. Cannot change mcp permissions." + }, + "mcp.tool.mcp_permissions": { + "tr": "Geçerli MCP kipini ve bir aracın en düşük kipini bildirir. Modu sen değiştirme. Yönetici panelini tıklama. Kullanıcıya söyle ve dur.", + "en": "Report the current MCP mode and the minimum mode a tool needs. Do not change the mode yourself. Do not click the admin UI. Tell the user and stop." + }, + "mcp.tool.sv_get_layer_stack": { + "tr": "Etkin katman yığınını okur. Salt okunur.", + "en": "Read the active layer stack. Read-only." + }, + "mcp.tool.sv_list_audio_sources": { + "tr": "Ayarlanmış ses girişi kaynaklarını listeler. Salt okunur.", + "en": "List configured audio input sources. Read-only." + }, + "mcp.tool.sv_get_analysis": { + "tr": "Canlı çözümleme ölçümlerini okur: tonalite, akor, gürlük, perde ve bantlar. Vuruş eşiği vermez.", + "en": "Read live analysis metrics: key, chord, loudness, pitch, bands. Does not expose onset thresholds." + }, + "mcp.tool.sv_diagnose_audio": { + "tr": "Ses yakalama tanısını okur. Aygıt onarmaz.", + "en": "Read the audio capture diagnosis. Does not repair devices." + }, + "mcp.tool.sv_set_audio_sources": { + "tr": "Ses girişi karışımını değiştirir. Aygıt onarmaz.", + "en": "Replace the audio input mix. Does not repair devices." + }, + "mcp.tool.sv_set_mapping": { + "tr": "Bir ekran için projeksiyon eşlemesini ayarlar: açma, köşeler, kırpma, kenar karışımı ve maskeler. Yeni bir ağ bağı açmaz.", + "en": "Set projection mapping for one display: enable, corners, crop, edge blend, masks. Does not open a new network bind." + }, + "mcp.tool.sv_repair_audio": { + "tr": "Ses onarımını çalıştırır. Her şey kipini ister. Sistem penceresi açmaz.", + "en": "Run audio repair. Requires Everything. Does not open a system dialog." + }, + "mcp.tool.sv_rotate_stream_token": { + "tr": "OBS veya uzaktan kumanda yayın jetonunu döndürür. Her şey kipini ister. Bağlantı adresini değiştirmez.", + "en": "Rotate the OBS or remote stream token. Requires Everything. Does not change the bind address." + }, + "mcp.tool.sv_updates_download": { + "tr": "Uygulama güncellemesini indirir. Her şey kipini ister.", + "en": "Download an application update. Requires Everything." + }, + "mcp.tool.sv_updates_install": { + "tr": "İndirilen uygulama güncellemesini kurar ve yeniden başlatır. Her şey kipini ister.", + "en": "Install a downloaded application update and restart. Requires Everything." + }, + "mcp.setup.prompt": { + "tr": "SoundVisualizer MCP kurulumunu yap. HTTP http://127.0.0.1:{port}/mcp, yalnız bu bilgisayar. Stdio komutu: {command}. Sunucuyu etkinleştir ve tools/list ile doğrula.", + "en": "Set up SoundVisualizer MCP. HTTP http://127.0.0.1:{port}/mcp, this computer only. Stdio command: {command}. Enable the server and confirm with tools/list." + }, + "mcp.setup.path.claude": { + "tr": "%APPDATA%\\Claude\\claude_desktop_config.json içindeki mcpServers", + "en": "mcpServers in %APPDATA%\\Claude\\claude_desktop_config.json" + }, + "mcp.setup.path.codex": { + "tr": "%USERPROFILE%\\.codex\\config.toml içindeki mcp_servers", + "en": "mcp_servers in %USERPROFILE%\\.codex\\config.toml" + }, + "mcp.setup.path.cursor": { + "tr": ".cursor/mcp.json veya %USERPROFILE%\\.cursor\\mcp.json", + "en": ".cursor/mcp.json or %USERPROFILE%\\.cursor\\mcp.json" + }, + "mcp.setup.path.grok": { + "tr": "yayınlanmış tek dosya yok; istemcinin MCP listesi", + "en": "no single published file; the client MCP list" + }, + "mcp.setup.path.grokbot": { + "tr": "yerel mcp.json yolu yok; istemcinin MCP sunucu listesi", + "en": "no local mcp.json path; the client MCP server list" + }, + "mcp.setup.path.ollama": { + "tr": "modeli Ollama olan MCP istemcisinin aynı stdio sunucusu, ayrı protokol yok", + "en": "the same stdio server on the MCP client whose model is Ollama, not a separate protocol" + }, + "mcp.setup.easy": { + "tr": "En kolay yol: bu istemi ajan sohbetine yapıştırın. Ajan kurulumu kendisi yapar.", + "en": "Easiest path: paste this prompt into the agent chat. The agent does the setup." + }, + "mcp.setup.manual": { + "tr": "Elle kurulum", + "en": "Manual setup" + }, + "mcp.setup.http": { + "tr": "HTTP uç noktası {url}. Yalnız bu bilgisayar. Port meşgulse başka porta kendiliğinden geçilmez.", + "en": "HTTP endpoint {url}. This computer only. If that port is busy, the app does not switch ports by itself." + }, + "mcp.card.failed": { + "tr": "Dinleyici başlamadı.", + "en": "Listener failed to start." + }, + "mcp.port.label": { + "tr": "Port", + "en": "Port" + }, + "mcp.port.apply": { + "tr": "Bu portu onayla", + "en": "Confirm this port" + }, + "mcp.port.invalid": { + "tr": "Port 1 ile 65535 arasında olmalı ve 8722 olamaz.", + "en": "The port must be from 1 to 65535 and cannot be 8722." + }, + "mcp.port.busy": { + "tr": "{port} kullanımda. MCP başka bir porta geçmedi. Farklı bir port yazıp onaylayın.", + "en": "{port} is in use. MCP did not switch ports. Enter a different port and confirm." + }, + "mcp.port.confirm": { + "tr": "MCP 127.0.0.1:{port} adresine bağlansın mı? Başka porta kendiliğinden geçmez.", + "en": "Bind MCP on 127.0.0.1:{port}? It will not switch to another port by itself." + }, + "mcp.card.copyPrompt": { + "tr": "Kurulum istemini kopyala", + "en": "Copy setup prompt" + } +}; + function mcpText(key, loc) { + const row = MCP_I18N[key]; + const use = loc === 'tr' || loc === 'en' ? loc : locale; + if (!row) return key; + return use === 'tr' ? row.tr : row.en; + } + if (typeof document === 'undefined') { + if (typeof module !== 'undefined' && module.exports) module.exports = { mcpText: mcpText, MCP_I18N: MCP_I18N }; + return; + } + document.documentElement.lang = locale; - window.SVI18n = { locale, t: translate }; + window.SVI18n = { locale, t: translate, mcpText: function (key, loc) { return mcpText(key, loc || locale); } }; const nativeAlert = window.alert.bind(window); const nativeConfirm = window.confirm.bind(window); diff --git a/src/shared/late-event.js b/src/shared/late-event.js new file mode 100644 index 0000000..4af685e --- /dev/null +++ b/src/shared/late-event.js @@ -0,0 +1,52 @@ +'use strict'; +(function () { +/* Son gelen değeri tutar. + * + * Electron, dinleyici kurulmadan gönderilen ipcRenderer mesajını atar. + * Görselleştirici çalan parçayı ancak yapılandırmayı istedikten sonra + * dinliyor; ana süreç ise pencere yüklenir yüklenmez yolluyor. Müzik + * zaten çalıyorsa o ilk örnek düşüyor ve sonraki yoklamalar aynı parçayı + * fark saymıyor. Duraklatıp devam etmek yeni bir fark olduğu için + * oynatıcı ancak o zaman çıkıyor. + * + * Aynı anda tek abone var; yenisi eskisinin yerine geçer. */ +function createLateEvent() { + let listener = null; + let seen = false; + let value; + return { + push(next) { + seen = true; + value = next; + if (listener) listener(next); + }, + subscribe(cb) { + listener = typeof cb === 'function' ? cb : null; + if (seen && listener) listener(value); + }, + seen() { return seen; }, + }; +} + +/* Abone olduktan sonra ana süreçteki güncel örneği al. + Bu arada canlı bir mesaj geldiyse onu ezme: o daha yeni olabilir. */ +async function catchUp(live, api) { + let seen = false; + if (api && typeof api.onNowPlaying === 'function') { + api.onNowPlaying((st) => { + seen = true; + if (live) live.state = st; + }); + } + if (api && typeof api.nowPlayingCurrent === 'function') { + try { + const cur = await api.nowPlayingCurrent(); + if (!seen && cur && cur.has && live) live.state = cur; + } catch { /* oturum yok */ } + } +} + +const api = { createLateEvent, catchUp }; +if (typeof module !== 'undefined' && module.exports) module.exports = api; +if (typeof window !== 'undefined') window.SVLateEvent = api; +})(); diff --git a/src/shared/mcp.js b/src/shared/mcp.js new file mode 100644 index 0000000..c3c8ffd --- /dev/null +++ b/src/shared/mcp.js @@ -0,0 +1,1373 @@ +'use strict'; +/* MCP tool catalog, permission gates, and JSON-RPC handler. + The Electron host calls into the running app. This file decides + which write group a tool needs. Apply tools use what already exists. + Edit tools create or change structure. Reads need only the master switch. */ +(function () { + const MODES = ['read', 'apply', 'write', 'full', 'everything']; + const MODE_RANK = { read: 1, apply: 2, write: 3, full: 4, everything: 5 }; + const GROUP_MODE = { + sceneApply: 'apply', effectApply: 'apply', presetApply: 'apply', + sceneEdit: 'write', effectEdit: 'write', presetEdit: 'write', + export: 'write', autovj: 'write', + blackout: 'full', output: 'full', timeline: 'full', + updates: 'everything', repairAudio: 'everything', streamToken: 'everything', + controlBindings: 'everything', + }; + const ALLOW_KEYS = MODES.slice(); + const GROUP_LABEL = { + sceneApply: 'scene.apply', sceneEdit: 'scene.edit', + effectApply: 'effect.apply', effectEdit: 'effect.edit', + presetApply: 'preset.apply', presetEdit: 'preset.edit', + output: 'output', export: 'export', blackout: 'blackout', + autovj: 'autovj', timeline: 'timeline', + updates: 'updates', repairAudio: 'repair-audio', streamToken: 'stream-token', + controlBindings: 'control', + }; + const DEFAULT_PORT = 38471; + const DEFAULT_MCP = { enabled: false, mode: 'read', port: DEFAULT_PORT }; + const SCENE_KEYS = [ + 'background', 'visualizer', 'layers', 'layerStack', 'layerGroups', 'crossfade', + 'geometry', 'postfx', 'logo', 'images', 'media', 'text', 'modulation', + 'transition', 'custom', 'milkdrop', 'feedback', + ]; + const EFFECT_TYPES = [ + 'bloom', 'chroma', 'glitch', 'grain', 'crt', 'pixelate', 'kaleido', 'mirror', + 'grade', 'vignette', 'trails', 'edge', 'zoomblur', 'ripple', 'posterize', + 'blur', 'radialblur', 'motionblur', 'tiltshift', 'dof', 'sharpen', 'emboss', + 'dither', 'halftone', 'ascii', 'hatch', 'paint', 'vhs', 'datamosh', 'slitscan', + 'lens', 'twirl', 'polar', 'gradientmap', 'levels', 'threshold', 'solarize', + 'godrays', 'badtv', 'starfilter', + ]; + const LAYER_DEFAULTS = { + id: null, name: '', enabled: true, kind: 'visualizer', type: 'bars', presetId: null, + blend: 'normal', opacity: 1, + transform: { scale: 1, rotate: 0, x: 0, y: 0, flipX: false, flipY: false }, + audio: { band: 'bass', opacity: 0, scale: 0, rotate: 0 }, + settings: {}, solo: false, muted: false, locked: false, group: '', + mask: { type: 'none', x: 0.5, y: 0.5, w: 0.6, h: 0.6, angle: 0, feather: 0.1, invert: false, from: '' }, + postfx: [], + }; + const PROTOCOLS = ['2024-11-05', '2025-03-26', '2025-06-18']; + const TOOLS = []; + + function clone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); } + function uid(prefix) { return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } + function normalizePort(value) { + const n = Number(value); + if (!Number.isInteger(n) || n < 1 || n > 65535 || n === 8722) return DEFAULT_PORT; + return n; + } + function normalizeMcp(raw) { + const src = raw && typeof raw === 'object' ? raw : {}; + const mode = MODES.indexOf(src.mode) >= 0 ? src.mode : 'read'; + return { enabled: src.enabled === true, mode: mode, port: normalizePort(src.port) }; + } + function mcpI18n() { + if (mcpI18n.cached !== undefined) return mcpI18n.cached; + mcpI18n.cached = null; + try { if (typeof require === 'function') mcpI18n.cached = require('./i18n'); } catch (e) { mcpI18n.cached = null; } + return mcpI18n.cached; + } + function text(key, locale) { + const loc = locale === 'tr' ? 'tr' : 'en'; + const mod = mcpI18n(); + if (mod && typeof mod.mcpText === 'function') return mod.mcpText(key, loc); + if (typeof window !== 'undefined' && window.SVI18n && window.SVI18n.mcpText) return window.SVI18n.mcpText(key, loc); + return key; + } + function localeOf(ctx) { + try { + const v = ctx && ctx.locale ? ctx.locale() : null; + if (v === 'tr' || v === 'en') return v; + } catch (e) { /* ignore */ } + if (typeof window !== 'undefined' && window.SVI18n && (window.SVI18n.locale === 'tr' || window.SVI18n.locale === 'en')) return window.SVI18n.locale; + return 'en'; + } + function minMode(group) { + if (!group || group === 'routed') return 'read'; + if (group === 'mcp' || group === 'never') return null; + return GROUP_MODE[group] || 'everything'; + } + function modeAllows(mcp, group) { + const need = minMode(group); + if (!need || !mcp || mcp.enabled !== true) return false; + return MODE_RANK[mcp.mode] >= MODE_RANK[need]; + } + function modeWord(mode, locale) { return text('mcp.mode.' + mode + '.word', locale); } + function modeBlockError(locale, need) { + return text('mcp.err.mode', locale).replace('{mode}', modeWord(need, locale)); + } + function configOf(ctx) { + const c = ctx && ctx.getConfig ? ctx.getConfig() : null; + return c && typeof c === 'object' ? c : {}; + } + function revisionOf(ctx) { + try { return ctx && ctx.revision ? ctx.revision() : 0; } catch (e) { return 0; } + } + function denied(group) { + return { ok: false, error: 'Permission denied: ' + (GROUP_LABEL[group] || group) + ' is off. Enable that switch on the MCP card under Control. Writes stay off until the matching group is allowed.' }; + } + function fail(message) { return { ok: false, error: message }; } + function groupForPath(p) { + const path = String(p || ''); + if (!path || path === 'mcp' || path.indexOf('mcp.') === 0) return 'mcp'; + if (path === 'updates' || path.indexOf('updates.') === 0) return 'updates'; + if (path === 'stream.token' || path === 'stream.remoteToken') return 'streamToken'; + if (path === 'mapping' || path.indexOf('mapping.') === 0) return 'output'; + if (path === 'timeline' || path.indexOf('timeline.') === 0) return 'timeline'; + if (path === 'control' || path.indexOf('control.') === 0) return 'controlBindings'; + if (path === 'isBlackout' || path.indexOf('transition.blackout') === 0) return 'blackout'; + if (path.indexOf('export.') === 0 || path.indexOf('recording.') === 0) return 'export'; + if (path === 'background.transparent' || path === 'background.coverTaskbar' || path === 'background.transparentKey') return 'output'; + if (path.indexOf('display') === 0 || path.indexOf('stream.') === 0 || path.indexOf('textureShare.') === 0 || + path.indexOf('aspect.') === 0 || path.indexOf('floating.') === 0 || path.indexOf('power.') === 0 || + path.indexOf('lighting.') === 0 || path.indexOf('openrgb.') === 0 || path.indexOf('artnet.') === 0 || + path.indexOf('audio.sources') === 0) return 'output'; + if (path === 'postfx' || path.indexOf('postfx.') === 0 || path.indexOf('.postfx') >= 0 || + path === 'modulation' || path.indexOf('modulation.') === 0) return 'effectEdit'; + if (path.indexOf('milkdrop') === 0 || path.indexOf('userPresets') === 0 || + path.indexOf('custom.') === 0 || path.indexOf('feedback.') === 0) return 'presetEdit'; + return 'sceneEdit'; + } + function unsafeKey(k) { return k === '__proto__' || k === 'prototype' || k === 'constructor'; } + function setPath(obj, p, value) { + const keys = String(p).split('.'); + if (keys.some(unsafeKey)) return fail('Refusing unsafe config path.'); + let node = obj; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (typeof node[k] !== 'object' || node[k] === null || Array.isArray(node[k])) node[k] = {}; + node = node[k]; + } + node[keys[keys.length - 1]] = value; + return { ok: true }; + } + function getPath(obj, p) { + return String(p).split('.').reduce(function (x, k) { return x == null ? x : x[k]; }, obj); + } + function mergeObj(base, patch) { + const out = Object.assign({}, base || {}); + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return out; + for (const k of Object.keys(patch)) { + if (unsafeKey(k)) continue; + const v = patch[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) out[k] = mergeObj(out[k], v); + else out[k] = v; + } + return out; + } + function summarize(v, depth) { + if (depth == null) depth = 0; + if (typeof v === 'string') { + if (v.length > 240) return { truncated: true, length: v.length, head: v.slice(0, 80) }; + return v; + } + if (v == null || typeof v !== 'object') return v; + if (depth > 4) return '[depth]'; + if (Array.isArray(v)) return v.slice(0, 40).map(function (item) { return summarize(item, depth + 1); }); + const out = {}; + for (const k of Object.keys(v)) { + if (unsafeKey(k)) continue; + out[k] = summarize(v[k], depth + 1); + } + return out; + } + function publicFx(fx, index) { + fx = fx || {}; + return { index: index, id: fx.id || null, type: fx.type || '', enabled: fx.enabled !== false, params: fx.params || {} }; + } + function publicLayer(layer, index) { + const t = (layer && layer.transform) || {}; + return { + index: index, + id: layer && layer.id || null, + name: layer && layer.name || '', + kind: layer && layer.kind || '', + type: layer && layer.type || '', + enabled: !layer || layer.enabled !== false, + opacity: layer ? layer.opacity : 1, + blend: layer && layer.blend || 'normal', + transform: { + x: Number(t.x) || 0, y: Number(t.y) || 0, + scale: t.scale == null ? 1 : t.scale, rotate: Number(t.rotate) || 0, + flipX: !!t.flipX, flipY: !!t.flipY, + }, + settings: summarize(layer && layer.settings), + postfx: ((layer && layer.postfx) || []).map(publicFx), + }; + } + function visualState(cfg, ctx) { + cfg = cfg || {}; + const bg = cfg.background || {}; + const vis = cfg.visualizer || {}; + const md = cfg.milkdrop || {}; + return { + revision: revisionOf(ctx), + blackout: cfg.isBlackout === true, + activeSceneId: cfg._activeSceneId || null, + background: { type: bg.type || null, solidColor: bg.solidColor || null, transparent: !!bg.transparent, colors: (bg.gradient && bg.gradient.colors) || null }, + visualizer: { type: vis.type || null }, + layerStack: cfg.layerStack || null, + layers: (Array.isArray(cfg.layers) ? cfg.layers : []).map(publicLayer), + postfx: (Array.isArray(cfg.postfx) ? cfg.postfx : []).map(publicFx), + milkdrop: { presetId: md.presetId || '', name: md.name || '' }, + crossfade: cfg.crossfade || null, + }; + } + function withConfig(ctx, mutator) { + const cfg = clone(configOf(ctx)) || {}; + const result = mutator(cfg); + if (result && result.ok === false) return result; + if (ctx.setConfig) ctx.setConfig(cfg); + const rev = ctx.noteWrite ? ctx.noteWrite() : revisionOf(ctx); + return Object.assign({ ok: true, revision: rev }, result || {}); + } + function findScene(cfg, args) { + const list = Array.isArray(cfg.scenes) ? cfg.scenes : []; + if (args && args.id) { + const hit = list.find(function (s) { return s && s.id === args.id; }); + if (hit) return { list: list, scene: hit }; + } + if (args && args.name) { + const name = String(args.name).trim().toLowerCase(); + const hits = list.filter(function (s) { return s && String(s.name || '').trim().toLowerCase() === name; }); + if (hits.length === 1) return { list: list, scene: hits[0] }; + if (hits.length > 1) return { error: 'More than one scene is named "' + args.name + '". Pass id.' }; + } + return { list: list, scene: null }; + } + function applySceneData(cfg, data) { + const prevBg = clone(cfg.background); + for (const key of SCENE_KEYS) if (data && data[key] !== undefined) cfg[key] = clone(data[key]); + if (cfg.background && prevBg) { + cfg.background.transparent = !!prevBg.transparent; + if (prevBg.transparentKey != null) cfg.background.transparentKey = prevBg.transparentKey; + if (prevBg.coverTaskbar != null) cfg.background.coverTaskbar = !!prevBg.coverTaskbar; + } + } + function snapshotScene(cfg) { + const data = {}; + for (const key of SCENE_KEYS) if (cfg[key] !== undefined) data[key] = clone(cfg[key]); + return data; + } + function layersApi() { + if (typeof window !== 'undefined' && window.SVLayers && window.SVLayers.setStackEnabled) return window.SVLayers; + try { return require('../visualizer/layers.js'); } catch (e) { return null; } + } + function ensureLayers(cfg) { + const L = layersApi(); + if (L && L.setStackEnabled) { + if (!L.stackOn(cfg)) L.setStackEnabled(cfg, true); + if (!Array.isArray(cfg.layers)) cfg.layers = []; + return cfg.layers; + } + if (!Array.isArray(cfg.layers)) cfg.layers = []; + if (!cfg.layerStack || typeof cfg.layerStack !== 'object') cfg.layerStack = { enabled: true }; + cfg.layerStack.enabled = true; + return cfg.layers; + } + function normalizeFx(raw) { + const src = raw || {}; + return { + id: src.id || uid('fx_'), + type: String(src.type || ''), + enabled: src.enabled !== false, + params: src.params && typeof src.params === 'object' ? clone(src.params) : {}, + }; + } + function normalizeLayer(raw) { + const src = raw || {}; + const out = Object.assign({}, LAYER_DEFAULTS, src); + out.transform = Object.assign({}, LAYER_DEFAULTS.transform, src.transform || {}); + out.audio = Object.assign({}, LAYER_DEFAULTS.audio, src.audio || {}); + out.mask = Object.assign({}, LAYER_DEFAULTS.mask, src.mask || {}); + out.settings = src.settings && typeof src.settings === 'object' ? clone(src.settings) : {}; + out.postfx = Array.isArray(src.postfx) ? src.postfx.map(normalizeFx) : []; + if (!out.id) out.id = uid('ly_'); + return out; + } + function findLayer(cfg, args) { + const list = Array.isArray(cfg.layers) ? cfg.layers : []; + const id = args && (args.id || args.layerId); + if (id != null && id !== '') { + const index = list.findIndex(function (l) { return l && l.id === id; }); + if (index >= 0) return { list: list, index: index, layer: list[index] }; + } + if (args && args.index != null && list[Number(args.index)]) { + const index = Number(args.index); + return { list: list, index: index, layer: list[index] }; + } + return null; + } + function findFx(list, args) { + const arr = Array.isArray(list) ? list : []; + if (args && args.effectId) { + const index = arr.findIndex(function (fx) { return fx && fx.id === args.effectId; }); + if (index >= 0) return { index: index, fx: arr[index] }; + } + if (args && args.effectIndex != null && arr[Number(args.effectIndex)]) { + const index = Number(args.effectIndex); + return { index: index, fx: arr[index] }; + } + if (args && args.type) { + const hits = []; + arr.forEach(function (fx, index) { if (fx && fx.type === args.type) hits.push({ index: index, fx: fx }); }); + if (hits.length === 1) return hits[0]; + if (hits.length > 1) return { error: 'Several ' + args.type + ' effects match. Pass effectId or effectIndex.' }; + } + return null; + } + function defaultsApi() { + if (typeof window !== 'undefined' && window.SV && window.SV.defaultConfig) return window.SV; + const g = typeof global !== 'undefined' ? global : null; + if (g && g.window && g.window.SV && g.window.SV.defaultConfig) return g.window.SV; + try { + if (g) { + g.window = g.window || {}; + require('./defaults.js'); + return g.window.SV; + } + } catch (e) { /* optional */ } + return null; + } + function templatesApi() { + if (typeof window !== 'undefined' && window.SVTemplates) return window.SVTemplates; + try { return require('./templates.js'); } catch (e) { return null; } + } + function presetRecords(ctx) { + try { if (ctx.presets && ctx.presets.list) return ctx.presets.list() || []; } catch (e) { /* cold store */ } + return []; + } + function tool(name, group, description, fn) { + TOOLS.push({ + name: name, group: group, description: description, fn: fn, + inputSchema: { type: 'object', additionalProperties: true }, + }); + } + + function audioState(cfg, ctx) { + const live = (ctx.live && ctx.live()) || {}; + const auto = cfg.autovj || {}; + return { + bpm: live.bpm || 0, + bpmSource: live.bpm ? 'live' : (auto.bpmLock > 0 ? 'lock' : 'none'), + bpmLock: auto.bpmLock || 0, + confidence: live.confidence || 0, + level: live.level || 0, bass: live.bass || 0, mid: live.mid || 0, treble: live.treble || 0, + at: live.at || 0, + }; + } + + + function readSnapshot(ctx) { + const cfg = configOf(ctx); + const visual = visualState(cfg, ctx); + const layers = visual.layers || []; + const scenes = (Array.isArray(cfg.scenes) ? cfg.scenes : []).map(function (sc) { + return { id: sc && sc.id || null, name: sc && sc.name || '', active: !!(sc && cfg._activeSceneId && sc.id === cfg._activeSceneId) }; + }); + const globalFx = Array.isArray(cfg.postfx) ? cfg.postfx.map(publicFx) : []; + const showing = []; + globalFx.forEach(function (fx) { if (fx.enabled) showing.push({ scope: 'global', type: fx.type, id: fx.id }); }); + layers.forEach(function (layer) { + (layer.postfx || []).forEach(function (fx) { + if (fx && fx.enabled !== false) showing.push({ scope: 'layer', layerId: layer.id, type: fx.type || '', id: fx.id || null }); + }); + }); + let displays = null; + let nowPlaying = null; + let output = null; + try { displays = ctx.displays ? ctx.displays() : null; } catch (e) { displays = null; } + try { nowPlaying = ctx.nowPlaying ? ctx.nowPlaying() : null; } catch (e2) { nowPlaying = null; } + try { output = ctx.outputStatus ? ctx.outputStatus() : null; } catch (e3) { output = null; } + const stream = cfg.stream ? { enabled: !!cfg.stream.enabled, port: cfg.stream.port || null, lan: !!cfg.stream.lan } : null; + const texture = cfg.textureShare ? { enabled: !!cfg.textureShare.enabled, name: cfg.textureShare.name || '' } : null; + const audioCfg = cfg.audio || {}; + return { + ok: true, + revision: revisionOf(ctx), + permissions: normalizeMcp(cfg.mcp), + visual: visual, + activePreset: { milkdrop: visual.milkdrop, id: (cfg.milkdrop && cfg.milkdrop.presetId) || '' }, + scenes: scenes, + layersOn: layers.filter(function (layer) { return layer.enabled !== false; }), + effectsShowing: showing, + displays: displays, + displayConfig: cfg.display || null, + audio: audioState(cfg, ctx), + audioSources: audioCfg.sources || [], + nowPlaying: nowPlaying, + stack: { enabled: !!(cfg.layerStack && cfg.layerStack.enabled), layerStack: cfg.layerStack || null, layers: layers }, + stream: stream, + textureShare: texture, + output: output, + }; + } + tool('sv_get_state', null, 'Read the full show state: preset, effects, scenes, layers, displays, BPM, now playing, analysis, stack, stream, texture share, and audio. Read-only. Changes nothing.', function (args, ctx) { + const snapState = readSnapshot(ctx); + if (!ctx.analysis) return Object.assign(snapState, { analysis: null }); + return Promise.resolve(ctx.analysis()).then(function (r) { + snapState.analysis = r && r.analysis ? r.analysis : (r && r.ok !== false ? r : null); + return snapState; + }, function () { snapState.analysis = null; return snapState; }); + }); + tool('sv_get_visual_state', null, 'Read live visual state: layer positions, settings, per-layer effects, global effects, active scene. Read-only.', function (args, ctx) { + return Object.assign({ ok: true }, visualState(configOf(ctx), ctx)); + }); + tool('sv_get_preview', null, 'Read visual state plus a small JPEG of the live canvas when a window is open. Read-only. Use it between edits.', function (args, ctx) { + const state = Object.assign({ ok: true }, visualState(configOf(ctx), ctx)); + if (!ctx.capturePreview) { + state.preview = null; + state.previewError = 'Live canvas capture is not available in this process.'; + return state; + } + return Promise.resolve().then(function () { return ctx.capturePreview(); }).then(function (img) { + if (!img || img.error || !img.dataUrl) { + state.preview = null; + state.previewError = (img && img.error) || 'The preview canvas is not ready.'; + return state; + } + state.preview = img; + return state; + }).catch(function (e) { + state.preview = null; + state.previewError = String((e && e.message) || e); + return state; + }); + }); + tool('sv_get_audio', null, 'Read BPM and level/bass/mid/treble. Read-only.', function (args, ctx) { + return Object.assign({ ok: true, revision: revisionOf(ctx) }, audioState(configOf(ctx), ctx)); + }); + tool('sv_get_now_playing', null, 'Read the current track. Read-only.', function (args, ctx) { + let track = null; + try { track = ctx.nowPlaying ? ctx.nowPlaying() : null; } catch (e) { track = null; } + return { ok: true, nowPlaying: track || null }; + }); + tool('sv_list_scenes', null, 'List saved scenes. Read-only.', function (args, ctx) { + return { ok: true, scenes: (configOf(ctx).scenes || []).map(function (s) { return { id: s.id, name: s.name, createdAt: s.createdAt || null }; }) }; + }); + tool('sv_get_scene', null, 'Read one saved scene. Read-only.', function (args, ctx) { + const found = findScene(configOf(ctx), args); + if (found.error) return fail(found.error); + if (!found.scene) return fail('Scene not found.'); + return { ok: true, scene: { id: found.scene.id, name: found.scene.name, data: summarize(found.scene.data) } }; + }); + tool('sv_list_layers', null, 'List layers with position, settings, and effects. Read-only.', function (args, ctx) { + return { ok: true, layers: (configOf(ctx).layers || []).map(publicLayer) }; + }); + tool('sv_get_layer', null, 'Read one layer. Read-only.', function (args, ctx) { + const found = findLayer(configOf(ctx), args); + if (!found) return fail('Layer not found.'); + return { ok: true, layer: publicLayer(found.layer, found.index) }; + }); + tool('sv_list_effects', null, 'List global and per-layer effects plus the built-in type catalog. Read-only.', function (args, ctx) { + const cfg = configOf(ctx); + return { + ok: true, + catalog: EFFECT_TYPES.slice(), + global: (cfg.postfx || []).map(publicFx), + layers: (cfg.layers || []).map(function (layer, index) { + return { id: layer.id, index: index, postfx: (layer.postfx || []).map(publicFx) }; + }), + }; + }); + tool('sv_list_presets', null, 'List library presets and user color presets. Read-only.', function (args, ctx) { + const cfg = configOf(ctx); + return { + ok: true, + library: presetRecords(ctx).map(function (p) { return { id: p.id, name: p.name || '', kind: p.kind || '' }; }), + colors: (cfg.userPresets || []).map(function (p) { return { id: p.id, name: p.name || '', colors: p.colors || [] }; }), + }; + }); + tool('sv_list_displays', null, 'List displays. Read-only.', function (args, ctx) { + let displays = []; + try { displays = ctx.displays ? ctx.displays() : []; } catch (e) { displays = []; } + return { ok: true, displays: displays || [] }; + }); + tool('sv_get_output_status', null, 'Read visualizer and stream status. Read-only. Stream tokens are not included.', function (args, ctx) { + let status = null; + try { status = ctx.outputStatus ? ctx.outputStatus() : null; } catch (e) { status = null; } + const cfg = configOf(ctx); + return { + ok: true, + status: status, + display: cfg.display || null, + displays: (function () { try { return ctx.displays ? ctx.displays() : null; } catch (e) { return null; } })(), + stream: cfg.stream ? { enabled: !!cfg.stream.enabled, port: cfg.stream.port, lan: !!cfg.stream.lan } : null, + textureShare: cfg.textureShare ? { enabled: !!cfg.textureShare.enabled, name: cfg.textureShare.name || '' } : null, + audioSources: (cfg.audio && cfg.audio.sources) || [], + }; + }); + tool('sv_get_config', null, 'Read config or one dotted path. Stream tokens are redacted. Read-only.', function (args, ctx) { + const cfg = clone(configOf(ctx)) || {}; + if (cfg.stream) { + if (cfg.stream.token) cfg.stream.token = '[redacted]'; + if (cfg.stream.remoteToken) cfg.stream.remoteToken = '[redacted]'; + } + if (args && args.path) return { ok: true, path: args.path, value: summarize(getPath(cfg, args.path)) }; + return { ok: true, config: summarize(cfg) }; + }); + tool('sv_list_permissions', null, 'Report the current MCP mode and the minimum mode a tool needs. Do not change the mode yourself. Do not click the admin UI. Tell the user and stop.', function (args, ctx) { + return permissionReport(args, ctx); + }); + tool('sv_get_timeline', null, 'Read timeline data. Read-only.', function (args, ctx) { + return { ok: true, timeline: summarize(configOf(ctx).timeline || null) }; + }); + tool('sv_get_clipdeck', null, 'Read clip deck slots. Read-only.', function (args, ctx) { + return { ok: true, clipdeck: summarize(configOf(ctx).clipdeck || null) }; + }); + tool('sv_get_autovj', null, 'Read Auto VJ settings. Read-only.', function (args, ctx) { + return { ok: true, autovj: configOf(ctx).autovj || null }; + }); + + tool('sv_apply_scene', 'sceneApply', 'Switch to an existing scene by id or name. Does not create or edit scene contents.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findScene(cfg, args); + if (found.error) return fail(found.error); + if (!found.scene || !found.scene.data) return fail('Scene not found.'); + applySceneData(cfg, found.scene.data); + cfg._activeSceneId = found.scene.id; + return { sceneId: found.scene.id, name: found.scene.name, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_set_visualizer_type', 'sceneApply', 'Switch the visualizer to an existing mode id and show it on the live stack.', function (args, ctx) { + if (!args || !args.type) return fail('type is required.'); + return withConfig(ctx, function (cfg) { + const spec = { type: String(args.type) }; + if (args.presetId != null) spec.presetId = args.presetId; + const L = layersApi(); + if (L && L.adoptVisualizer) L.adoptVisualizer(cfg, spec); + else cfg.visualizer = Object.assign({}, cfg.visualizer, { type: spec.type }); + return { type: cfg.visualizer.type, presetId: cfg.custom && cfg.custom.visualizerId || null }; + }); + }); + tool('sv_set_background_type', 'sceneApply', 'Switch the background to an existing mode id.', function (args, ctx) { + if (!args || !args.type) return fail('type is required.'); + return withConfig(ctx, function (cfg) { + cfg.background = Object.assign({}, cfg.background, { type: String(args.type) }); + return { type: cfg.background.type }; + }); + }); + tool('sv_set_layer_enabled', 'sceneApply', 'Show or hide an existing layer and turn the layer stack on. Does not change layer contents.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + ensureLayers(cfg); + found.layer.enabled = !(args && args.enabled === false); + if (found.layer.enabled) { + const L = layersApi(); + if (L && L.revealLayer) L.revealLayer(cfg, found.layer); + else found.layer.muted = false; + } + return { layer: publicLayer(found.layer, found.index) }; + }); + }); + tool('sv_set_crossfade', 'sceneApply', 'Move the existing A/B crossfader (0..1).', function (args, ctx) { + const value = Number(args && args.value); + if (!isFinite(value)) return fail('value is required.'); + return withConfig(ctx, function (cfg) { + cfg.crossfade = Object.assign({ enabled: true, value: 0 }, cfg.crossfade, { value: Math.max(0, Math.min(1, value)) }); + return { crossfade: cfg.crossfade }; + }); + }); + tool('sv_apply_template', 'sceneApply', 'Apply an existing built-in template by id.', function (args, ctx) { + const T = templatesApi(); + const SV = defaultsApi(); + if (!T || !SV) return fail('Template library is not available.'); + const id = args && (args.id || args.name); + const tpl = (T.TEMPLATES || []).find(function (t) { return t && (t.id === id || t.name === id); }); + if (!tpl) return fail('Template not found.'); + return withConfig(ctx, function (cfg) { + const next = T.apply(clone(cfg), tpl, { defaultConfig: SV.defaultConfig, deepMerge: SV.deepMerge, clone: SV.clone }); + for (const k of Object.keys(cfg)) delete cfg[k]; + Object.assign(cfg, next); + return { templateId: tpl.id, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_timeline_transport', 'timeline', 'Play, pause, stop, or seek the existing timeline via the admin transport.', function (args, ctx) { + if (!ctx.timeline) return fail('Timeline transport is not available. Keep the admin window open.'); + return Promise.resolve(ctx.timeline(args && args.action, args && args.time)).then(function (r) { + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + tool('sv_trigger_clip', 'sceneApply', 'Fire an existing clip-deck slot. Does not edit the grid.', function (args, ctx) { + if (!ctx.launchClip) return fail('Clip deck is not available. Keep the admin window open.'); + return Promise.resolve(ctx.launchClip(args || {})).then(function (r) { + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + tool('sv_stop_clips', 'sceneApply', 'Stop every playing clip-deck slot. Does not edit the grid.', function (args, ctx) { + if (!ctx.stopClips) return fail('Clip deck is not available. Keep the admin window open.'); + return Promise.resolve(ctx.stopClips()).then(function (r) { + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + tool('sv_set_autovj', 'autovj', 'Turn Auto VJ on or off and choose how it walks existing scenes, modes, or palettes.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const next = Object.assign({ enabled: false, source: 'visualizers', unit: 'bars', interval: 8, order: 'sequential', bpmLock: 0 }, cfg.autovj); + ['enabled', 'source', 'interval', 'unit', 'order', 'bpmLock', 'paletteSource', 'visualizerTargets'].forEach(function (k) { + if (args && args[k] !== undefined) next[k] = args[k]; + }); + cfg.autovj = next; + return { autovj: next }; + }); + }); + + tool('sv_create_scene', 'sceneEdit', 'Create a scene from the current visuals. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + if (!Array.isArray(cfg.scenes)) cfg.scenes = []; + const scene = { id: uid('sc_'), name: (args && args.name) || ('Scene ' + (cfg.scenes.length + 1)), createdAt: Date.now(), data: snapshotScene(cfg) }; + cfg.scenes.push(scene); + cfg._activeSceneId = scene.id; + return { scene: { id: scene.id, name: scene.name } }; + }); + }); + tool('sv_update_scene', 'sceneEdit', 'Overwrite an existing scene with the current visuals. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findScene(cfg, args); + if (found.error) return fail(found.error); + if (!found.scene) return fail('Scene not found.'); + found.scene.data = snapshotScene(cfg); + cfg._activeSceneId = found.scene.id; + return { sceneId: found.scene.id }; + }); + }); + tool('sv_rename_scene', 'sceneEdit', 'Rename a scene. Authoring.', function (args, ctx) { + if (!args || !args.name) return fail('name is required.'); + return withConfig(ctx, function (cfg) { + const found = findScene(cfg, args); + if (!found.scene) return fail('Scene not found.'); + found.scene.name = String(args.name); + return { sceneId: found.scene.id, name: found.scene.name }; + }); + }); + tool('sv_delete_scene', 'sceneEdit', 'Delete a scene. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findScene(cfg, args); + if (!found.scene) return fail('Scene not found.'); + cfg.scenes = found.list.filter(function (s) { return s !== found.scene; }); + if (cfg._activeSceneId === found.scene.id) cfg._activeSceneId = null; + return { deleted: found.scene.id }; + }); + }); + tool('sv_add_layer', 'sceneEdit', 'Add a layer and turn the layer stack on. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const list = ensureLayers(cfg); + const raw = Object.assign({}, (args && args.layer) || {}); + if (args && args.kind) raw.kind = args.kind; + if (args && args.type) raw.type = args.type; + if (args && args.name) raw.name = args.name; + if (args && args.settings) raw.settings = args.settings; + if (args && args.transform) raw.transform = args.transform; + const layer = normalizeLayer(raw); + list.push(layer); + return { layer: publicLayer(layer, list.length - 1), visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_update_layer', 'sceneEdit', 'Change layer name, type, opacity, blend, transform, or settings. Does not add effects. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + const patch = Object.assign({}, (args && args.patch) || {}); + if (patch.postfx) return fail('Layer effects are changed with the effect tools, not sv_update_layer.'); + ['name', 'kind', 'type', 'enabled', 'opacity', 'blend', 'solo', 'muted', 'locked', 'group', 'presetId'].forEach(function (k) { + if (patch[k] !== undefined) found.layer[k] = patch[k]; + }); + if (patch.transform) found.layer.transform = mergeObj(found.layer.transform, patch.transform); + if (patch.settings) found.layer.settings = mergeObj(found.layer.settings, patch.settings); + if (patch.audio) found.layer.audio = mergeObj(found.layer.audio, patch.audio); + if (patch.mask) found.layer.mask = mergeObj(found.layer.mask, patch.mask); + if (found.layer.enabled !== false && (patch.type !== undefined || patch.presetId !== undefined || patch.kind !== undefined)) { + const L = layersApi(); + if (L && L.revealLayer) L.revealLayer(cfg, found.layer); + } + return { layer: publicLayer(found.layer, found.index), visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_set_layer_position', 'sceneEdit', 'Set layer x, y, scale, and rotate. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + const t = Object.assign({ scale: 1, rotate: 0, x: 0, y: 0, flipX: false, flipY: false }, found.layer.transform); + ['x', 'y', 'scale', 'rotate', 'flipX', 'flipY'].forEach(function (k) { + if (args && args[k] !== undefined) t[k] = args[k]; + }); + found.layer.transform = t; + return { layer: publicLayer(found.layer, found.index), visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_set_layer_settings', 'sceneEdit', 'Merge settings on an existing layer. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + if (args && args.settings && typeof args.settings === 'object') found.layer.settings = mergeObj(found.layer.settings, args.settings); + else if (args && args.key) { + if (unsafeKey(args.key)) return fail('Refusing unsafe settings key.'); + const settings = Object.assign({}, found.layer.settings); + settings[args.key] = args.value; + found.layer.settings = settings; + } else return fail('settings or key is required.'); + return { layer: publicLayer(found.layer, found.index) }; + }); + }); + tool('sv_remove_layer', 'sceneEdit', 'Remove a layer. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + found.list.splice(found.index, 1); + return { removed: found.layer.id, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_reorder_layers', 'sceneEdit', 'Reorder layers by id list. Authoring.', function (args, ctx) { + const ids = args && args.ids; + if (!Array.isArray(ids)) return fail('ids array is required.'); + return withConfig(ctx, function (cfg) { + const list = Array.isArray(cfg.layers) ? cfg.layers : []; + const map = {}; + list.forEach(function (layer) { if (layer && layer.id) map[layer.id] = layer; }); + const next = []; + ids.forEach(function (id) { if (map[id]) { next.push(map[id]); delete map[id]; } }); + list.forEach(function (layer) { if (layer && layer.id && map[layer.id]) next.push(layer); }); + cfg.layers = next; + return { layers: next.map(publicLayer) }; + }); + }); + tool('sv_set_text', 'sceneEdit', 'Edit the text overlay, including a lyrics or now-playing source. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { cfg.text = mergeObj(cfg.text || {}, (args && args.patch) || {}); return { text: summarize(cfg.text) }; }); + }); + tool('sv_set_logo', 'sceneEdit', 'Edit logo settings. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { cfg.logo = mergeObj(cfg.logo || {}, (args && args.patch) || {}); return { logo: summarize(cfg.logo) }; }); + }); + tool('sv_set_media', 'sceneEdit', 'Edit media-layer settings. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { cfg.media = mergeObj(cfg.media || {}, (args && args.patch) || {}); return { media: summarize(cfg.media) }; }); + }); + tool('sv_set_geometry', 'sceneEdit', 'Edit geometry settings. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { cfg.geometry = mergeObj(cfg.geometry || {}, (args && args.patch) || {}); return { geometry: summarize(cfg.geometry) }; }); + }); + + tool('sv_set_effect_enabled', 'effectApply', 'Enable or disable an effect already on the global chain.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findFx(cfg.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Effect not found.'); + found.fx.enabled = !(args && args.enabled === false); + return { effect: publicFx(found.fx, found.index) }; + }); + }); + tool('sv_set_effect_param', 'effectApply', 'Change parameters of a global effect that already exists.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findFx(cfg.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Effect not found.'); + found.fx.params = mergeObj(found.fx.params, (args && args.params) || {}); + return { effect: publicFx(found.fx, found.index) }; + }); + }); + tool('sv_set_layer_effect_enabled', 'effectApply', 'Enable or disable an effect already on a layer.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const layer = findLayer(cfg, args); + if (!layer) return fail('Layer not found.'); + const found = findFx(layer.layer.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Layer effect not found.'); + found.fx.enabled = !(args && args.enabled === false); + return { layerId: layer.layer.id, effect: publicFx(found.fx, found.index) }; + }); + }); + tool('sv_set_layer_effect_param', 'effectApply', 'Change parameters of an effect already on a layer.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const layer = findLayer(cfg, args); + if (!layer) return fail('Layer not found.'); + const found = findFx(layer.layer.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Layer effect not found.'); + found.fx.params = mergeObj(found.fx.params, (args && args.params) || {}); + return { layerId: layer.layer.id, effect: publicFx(found.fx, found.index), visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_set_modulation_enabled', 'effectApply', 'Turn the modulation matrix on or off without editing routes.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + cfg.modulation = Object.assign({}, cfg.modulation, { enabled: !(args && args.enabled === false) }); + return { enabled: cfg.modulation.enabled }; + }); + }); + tool('sv_set_macro', 'effectApply', 'Set an existing macro fader.', function (args, ctx) { + const index = Number(args && args.index); + if (!isFinite(index)) return fail('index is required.'); + return withConfig(ctx, function (cfg) { + const macros = cfg.modulation && cfg.modulation.macros; + if (!macros || !macros[index]) return fail('Macro not found.'); + macros[index].value = Number(args.value) || 0; + return { index: index, value: macros[index].value }; + }); + }); + tool('sv_add_effect', 'effectEdit', 'Add an effect to the global chain. Authoring.', function (args, ctx) { + if (!args || !args.type) return fail('type is required.'); + return withConfig(ctx, function (cfg) { + if (!Array.isArray(cfg.postfx)) cfg.postfx = []; + const fx = normalizeFx({ type: args.type, params: args.params, enabled: args.enabled }); + cfg.postfx.push(fx); + return { effect: publicFx(fx, cfg.postfx.length - 1), knownType: EFFECT_TYPES.indexOf(args.type) >= 0 }; + }); + }); + tool('sv_remove_effect', 'effectEdit', 'Remove a global effect. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const found = findFx(cfg.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Effect not found.'); + cfg.postfx.splice(found.index, 1); + return { removed: found.fx.id || found.index }; + }); + }); + tool('sv_add_layer_effect', 'effectEdit', 'Add an effect onto a specific layer. Authoring. Read state afterwards to continue.', function (args, ctx) { + if (!args || !args.type) return fail('type is required.'); + return withConfig(ctx, function (cfg) { + const found = findLayer(cfg, args); + if (!found) return fail('Layer not found.'); + if (!Array.isArray(found.layer.postfx)) found.layer.postfx = []; + const fx = normalizeFx({ type: args.type, params: args.params, enabled: args.enabled }); + found.layer.postfx.push(fx); + return { layerId: found.layer.id, effect: publicFx(fx, found.layer.postfx.length - 1), knownType: EFFECT_TYPES.indexOf(args.type) >= 0, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_remove_layer_effect', 'effectEdit', 'Remove an effect from a layer. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const layer = findLayer(cfg, args); + if (!layer) return fail('Layer not found.'); + const found = findFx(layer.layer.postfx, args); + if (!found || found.error) return fail((found && found.error) || 'Layer effect not found.'); + layer.layer.postfx.splice(found.index, 1); + return { removed: found.fx.id || found.index, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_add_modulation_route', 'effectEdit', 'Add a modulation route. Authoring.', function (args, ctx) { + const route = (args && args.route) || args; + if (!route || !route.source || !route.target) return fail('source and target are required.'); + return withConfig(ctx, function (cfg) { + if (!cfg.modulation) cfg.modulation = { enabled: true, routes: [] }; + if (!Array.isArray(cfg.modulation.routes)) cfg.modulation.routes = []; + const made = Object.assign({ id: uid('mod_') }, route); + delete made.route; + cfg.modulation.routes.push(made); + return { route: made }; + }); + }); + tool('sv_remove_modulation_route', 'effectEdit', 'Remove a modulation route. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const routes = cfg.modulation && cfg.modulation.routes; + if (!routes) return fail('No modulation routes.'); + const index = args && args.index != null ? Number(args.index) : routes.findIndex(function (r) { return r && r.id === args.id; }); + if (!routes[index]) return fail('Route not found.'); + const removed = routes.splice(index, 1)[0]; + return { removed: removed.id || index }; + }); + }); + + tool('sv_load_preset', 'presetApply', 'Load an existing library preset into MilkDrop. Does not write a new preset file.', function (args, ctx) { + if (!args || !args.id) return fail('id is required.'); + if (!ctx.presets || !ctx.presets.get) return fail('Preset store is not available.'); + const preset = ctx.presets.get(args.id); + if (!preset) return fail('Preset not found: ' + args.id); + return withConfig(ctx, function (cfg) { + cfg.milkdrop = Object.assign({}, cfg.milkdrop, { + presetId: preset.id, + name: preset.name || '', + source: typeof preset.source === 'string' ? preset.source : ((cfg.milkdrop && cfg.milkdrop.source) || ''), + }); + return { presetId: preset.id, name: preset.name || '', kind: preset.kind || '' }; + }); + }); + tool('sv_apply_color_preset', 'presetApply', 'Apply an existing user or built-in color preset. Does not create one.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const SV = defaultsApi(); + const builtin = (SV && SV.GRADIENT_PRESETS) || []; + const users = cfg.userPresets || []; + const id = args && args.id; + const name = args && args.name ? String(args.name).toLowerCase() : ''; + const pal = users.concat(builtin).find(function (p) { + if (!p || p.group) return false; + if (id && p.id === id) return true; + return name && String(p.name || '').toLowerCase() === name; + }); + if (!pal || !Array.isArray(pal.colors)) return fail('Color preset not found.'); + cfg.background = cfg.background || {}; + cfg.background.gradient = Object.assign({}, cfg.background.gradient, { colors: pal.colors.slice() }); + return { name: pal.name, colors: pal.colors.slice() }; + }); + }); + tool('sv_set_milkdrop_cycle', 'presetApply', 'Change how the existing MilkDrop library advances. Does not write preset source.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const next = Object.assign({}, cfg.milkdrop); + ['autoNext', 'autoOrder', 'autoFrom', 'autoTag', 'autoNextUnit', 'autoNextBars', 'trackAdvance', 'hardCut'].forEach(function (k) { + if (args && args[k] !== undefined) next[k] = args[k]; + }); + cfg.milkdrop = next; + return { presetId: next.presetId || '', autoNext: next.autoNext, autoOrder: next.autoOrder }; + }); + }); + tool('sv_save_preset', 'presetEdit', 'Write a preset file in the app preset store. Authoring.', function (args, ctx) { + if (!ctx.presets || !ctx.presets.save) return fail('Preset store is not available.'); + const src = args || {}; + /* Tür verilmediyse shader metni Studio görselleştiricisidir. Eski + varsayılan her kaydı MilkDrop sayıyordu; Studio listesi onu + göstermiyordu. Açık tür her zaman kazanır. */ + let kind = src.kind; + let engine = src.engine; + if (!kind) { + const shader = typeof src.shader === 'string' ? src.shader.trim() : ''; + if (shader) { + kind = 'visualizer'; + if (!engine) engine = 'shader'; + } else kind = 'milkdrop'; + } + const preset = { + id: src.id, + name: src.name || 'MCP preset', + kind: kind, + engine: engine, + source: src.source || '', + shader: src.shader, + controls: src.controls, + base: src.base, + overrides: src.overrides, + description: src.description, + author: src.author, + tags: src.tags, + }; + const saved = ctx.presets.save(preset); + if (!saved || saved.ok === false) return fail((saved && saved.error) || 'Could not save the preset.'); + const p = saved.preset || preset; + return { ok: true, preset: { id: p.id, name: p.name || preset.name, kind: p.kind || preset.kind, engine: p.engine || '' } }; + }); + tool('sv_delete_preset', 'presetEdit', 'Delete a preset file. Authoring.', function (args, ctx) { + if (!args || !args.id) return fail('id is required.'); + if (!ctx.presets || !ctx.presets.remove) return fail('Preset store is not available.'); + ctx.presets.remove(args.id); + return { ok: true, deleted: args.id }; + }); + tool('sv_set_milkdrop_source', 'presetEdit', 'Write MilkDrop source into the live config. Authoring. Loading an existing id is sv_load_preset.', function (args, ctx) { + if (!args || typeof args.source !== 'string') return fail('source is required.'); + return withConfig(ctx, function (cfg) { + cfg.milkdrop = Object.assign({}, cfg.milkdrop, { + source: args.source, + name: args.name != null ? args.name : ((cfg.milkdrop && cfg.milkdrop.name) || ''), + presetId: args.presetId != null ? args.presetId : ((cfg.milkdrop && cfg.milkdrop.presetId) || ''), + }); + return { name: cfg.milkdrop.name, presetId: cfg.milkdrop.presetId, sourceLength: args.source.length }; + }); + }); + tool('sv_create_color_preset', 'presetEdit', 'Save a user color preset. Authoring.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const colors = (args && args.colors) || (cfg.background && cfg.background.gradient && cfg.background.gradient.colors); + if (!Array.isArray(colors) || colors.length < 2) return fail('colors are required.'); + if (!Array.isArray(cfg.userPresets)) cfg.userPresets = []; + const preset = { id: uid('up_'), name: (args && args.name) || ('Palette ' + (cfg.userPresets.length + 1)), colors: colors.slice() }; + cfg.userPresets.push(preset); + return { preset: preset }; + }); + }); + tool('sv_delete_color_preset', 'presetEdit', 'Delete a user color preset. Authoring.', function (args, ctx) { + if (!args || !args.id) return fail('id is required.'); + return withConfig(ctx, function (cfg) { + const before = (cfg.userPresets || []).length; + cfg.userPresets = (cfg.userPresets || []).filter(function (p) { return p && p.id !== args.id; }); + if (cfg.userPresets.length === before) return fail('Color preset not found.'); + return { deleted: args.id }; + }); + }); + + function outputPatch(ctx, key, patch) { + return withConfig(ctx, function (cfg) { + cfg[key] = mergeObj(cfg[key] || {}, patch || {}); + return { value: summarize(cfg[key]) }; + }); + } + tool('sv_open_output', 'output', 'Open the visualizer on the selected displays.', function (args, ctx) { + const cfg = clone(configOf(ctx)) || {}; + const current = (cfg.display && cfg.display.ids) || []; + let ids = current; + if (args && Array.isArray(args.displayIds)) ids = args.displayIds; + else if (args && args.displayId != null) ids = [args.displayId]; + cfg.display = Object.assign({}, cfg.display, { ids: ids, id: ids[0] != null ? ids[0] : (cfg.display && cfg.display.id) }); + if (ctx.setConfig) ctx.setConfig(cfg); + const rev = ctx.noteWrite ? ctx.noteWrite() : undefined; + if (!ctx.openOutput) return { ok: true, revision: rev, display: cfg.display, opened: false }; + return Promise.resolve(ctx.openOutput(ids)).then(function (result) { + return { ok: true, revision: rev, display: cfg.display, opened: true, result: result == null ? true : result }; + }); + }); + tool('sv_close_output', 'output', 'Close visualizer windows.', function (args, ctx) { + if (!ctx.closeOutput) return fail('closeOutput is not available in this process.'); + return Promise.resolve(ctx.closeOutput(args && args.displayId)).then(function (result) { + return { ok: true, closed: true, result: result == null ? true : result }; + }); + }); + tool('sv_set_displays', 'output', 'Choose displays without opening them.', function (args, ctx) { + if (!args || !Array.isArray(args.ids)) return fail('ids array is required.'); + return withConfig(ctx, function (cfg) { + cfg.display = Object.assign({}, cfg.display, { ids: args.ids, id: args.ids[0] != null ? args.ids[0] : null }); + return { display: cfg.display }; + }); + }); + tool('sv_set_stream', 'output', 'Change OBS/web stream settings. Token fields are ignored here.', function (args, ctx) { + const patch = Object.assign({}, (args && args.patch) || {}); + const hadToken = Object.prototype.hasOwnProperty.call(patch, 'token') || Object.prototype.hasOwnProperty.call(patch, 'remoteToken'); + delete patch.token; + delete patch.remoteToken; + if (hadToken && !Object.keys(patch).length) return fail(text('mcp.err.token', localeOf(ctx))); + const result = outputPatch(ctx, 'stream', patch); + if (hadToken && result && result.ok !== false) result.tokenIgnored = true; + return result; + }); + tool('sv_set_texture_share', 'output', 'Change Spout/Syphon settings.', function (args, ctx) { return outputPatch(ctx, 'textureShare', args && args.patch); }); + tool('sv_set_aspect', 'output', 'Change aspect settings.', function (args, ctx) { return outputPatch(ctx, 'aspect', args && args.patch); }); + tool('sv_set_floating', 'output', 'Change floating window preferences.', function (args, ctx) { return outputPatch(ctx, 'floating', args && args.patch); }); + tool('sv_set_floating_open', 'output', 'Open or close the floating PiP window. Same window as the display-menu switch. Does not change opacity or click-through.', function (args, ctx) { + if (!args || typeof args.open !== 'boolean') return fail('open boolean is required.'); + if (!ctx.setFloatingOpen) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return Promise.resolve(ctx.setFloatingOpen(args.open)).then(function (r) { + if (r && r.ok === false) return r; + return { ok: true, open: !!(r && r.open) }; + }); + }); + tool('sv_set_power', 'output', 'Change fps cap and render scale.', function (args, ctx) { return outputPatch(ctx, 'power', args && args.patch); }); + tool('sv_set_lighting', 'output', 'Change Windows Dynamic Lighting settings.', function (args, ctx) { return outputPatch(ctx, 'lighting', args && args.patch); }); + tool('sv_set_openrgb', 'output', 'Change OpenRGB settings.', function (args, ctx) { return outputPatch(ctx, 'openrgb', args && args.patch); }); + tool('sv_set_artnet', 'output', 'Change Art-Net/DMX settings.', function (args, ctx) { return outputPatch(ctx, 'artnet', args && args.patch); }); + tool('sv_set_window_mode', 'output', 'Set transparent background and taskbar cover.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + cfg.background = Object.assign({}, cfg.background); + if (args && args.transparent != null) cfg.background.transparent = !!args.transparent; + if (args && args.coverTaskbar != null) cfg.background.coverTaskbar = !!args.coverTaskbar; + if (args && args.transparentKey != null) cfg.background.transparentKey = args.transparentKey; + return { transparent: !!cfg.background.transparent, coverTaskbar: !!cfg.background.coverTaskbar }; + }); + }); + tool('sv_start_export', 'export', 'Start an offline video export. Requires audioPath and outputPath.', function (args, ctx) { + if (!ctx.startExport) return fail('Export is not available in this process.'); + return Promise.resolve(ctx.startExport(args || {})).then(function (r) { return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); }); + }); + tool('sv_cancel_export', 'export', 'Cancel the running offline export.', function (args, ctx) { + if (!ctx.cancelExport) return fail('Export is not available in this process.'); + return Promise.resolve(ctx.cancelExport()).then(function (r) { return { ok: true, result: r == null ? true : r }; }); + }); + tool('sv_export_json', 'export', 'Write scenes or full config JSON to an explicit path. No save dialog.', function (args, ctx) { + if (!args || !args.path) return fail('path is required.'); + if (!ctx.writeText) return fail('File export is not available in this process.'); + const cfg = configOf(ctx); + const body = args.what === 'scenes' ? { type: 'sv-scenes', version: 1, scenes: cfg.scenes || [] } : cfg; + return Promise.resolve(ctx.writeText(args.path, JSON.stringify(body, null, 2))).then(function () { + return { ok: true, path: args.path, what: args.what || 'config' }; + }); + }); + tool('sv_save_snapshot', 'export', 'Capture the live canvas to a file. Reading a preview without saving is sv_get_preview.', function (args, ctx) { + if (!args || !args.path) return fail('path is required.'); + if (!ctx.capturePreview || !ctx.writeBinary) return fail('Snapshot capture is not available in this process.'); + return Promise.resolve(ctx.capturePreview()).then(function (img) { + if (!img || img.error || !img.dataUrl) return fail((img && img.error) || 'The preview canvas is not ready.'); + const m = String(img.dataUrl).match(/^data:([^;]+);base64,(.+)$/); + if (!m) return fail('Canvas did not return a data URL.'); + return Promise.resolve(ctx.writeBinary(args.path, Buffer.from(m[2], 'base64'))).then(function () { + return { ok: true, path: args.path, mime: m[1], width: img.width, height: img.height }; + }); + }); + }); + tool('sv_record_start', 'export', 'Start the admin live recorder.', function (args, ctx) { + if (!ctx.recordStart) return fail('Live recording is not available. Keep the admin window open.'); + return Promise.resolve(ctx.recordStart()).then(function (r) { return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); }); + }); + tool('sv_record_stop', 'export', 'Stop the live recorder. The app then asks where to save, same as the Record card.', function (args, ctx) { + if (!ctx.recordStop) return fail('Live recording is not available. Keep the admin window open.'); + return Promise.resolve(ctx.recordStop()).then(function (r) { return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); }); + }); + tool('sv_set_blackout', 'blackout', 'Black out or restore the show. state is on, off, or toggle. Scene data stays intact.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + const state = args && (args.state || (args.on === true ? 'on' : args.on === false ? 'off' : '')); + let next; + if (state === 'toggle') next = cfg.isBlackout !== true; + else if (state === 'on') next = true; + else if (state === 'off') next = false; + else return fail('state must be on, off, or toggle.'); + cfg.isBlackout = next; + return { blackout: next, visual: visualState(cfg, ctx) }; + }); + }); + tool('sv_set_blackout_transition', 'blackout', 'Set the blackout transition type and duration.', function (args, ctx) { + return withConfig(ctx, function (cfg) { + cfg.transition = Object.assign({}, cfg.transition); + if (args && args.type) cfg.transition.blackoutType = args.type; + if (args && args.duration != null) cfg.transition.blackoutDuration = Number(args.duration); + return { blackoutType: cfg.transition.blackoutType, blackoutDuration: cfg.transition.blackoutDuration }; + }); + }); + tool('sv_patch_config', 'routed', 'Set any other config path. Permission follows the path. Cannot change mcp permissions.', function (args, ctx) { + if (!args || !args.path) return fail('path is required.'); + const group = groupForPath(args.path); + if (group === 'mcp') return fail(text('mcp.err.mcp', localeOf(ctx))); + const mcp = normalizeMcp(configOf(ctx).mcp); + if (!mcp.enabled) return fail(text('mcp.err.disabled', localeOf(ctx)).replace('{mode}', modeWord(minMode(group) || 'everything', localeOf(ctx)))); + if (!modeAllows(mcp, group)) return fail(modeBlockError(localeOf(ctx), minMode(group) || 'everything')); + return withConfig(ctx, function (cfg) { + const wrote = setPath(cfg, args.path, args.value); + if (wrote.ok === false) return wrote; + return { path: args.path, group: GROUP_LABEL[group] || group }; + }); + }); + + + function permissionReport(args, ctx) { + const loc = localeOf(ctx); + const mcp = normalizeMcp(configOf(ctx).mcp); + const modes = MODES.map(function (id) { + return { + id: id, + label: text('mcp.mode.' + id + '.label', loc), + unlocks: text('mcp.mode.' + id + '.unlocks', loc), + active: mcp.enabled === true && id === mcp.mode, + }; + }); + const name = args && (args.tool || args.name); + let toolInfo = null; + if (name) { + let def = null; + for (let i = 0; i < TOOLS.length; i++) if (TOOLS[i].name === name) def = TOOLS[i]; + if (!def) toolInfo = { name: String(name), known: false, minimumMode: null }; + else { + const need = minMode(def.group) || 'read'; + toolInfo = { + name: def.name, + known: true, + minimumMode: need, + minimumModeLabel: modeWord(need, loc), + allowed: !!(mcp.enabled && (def.group === 'routed' || modeAllows(mcp, def.group))), + }; + } + } + return { + ok: true, + enabled: mcp.enabled, + mode: mcp.mode, + modeLabel: text('mcp.mode.' + mcp.mode + '.label', loc), + modes: modes, + tool: toolInfo, + note: text('mcp.perm.handsOff', loc), + }; + } + tool('mcp_permissions', null, 'Report the current MCP mode and the minimum mode a tool needs. Do not change the mode yourself. Do not click the admin UI. Tell the user and stop.', function (args, ctx) { + return permissionReport(args, ctx); + }); + tool('sv_get_layer_stack', null, 'Read the active layer stack. Read-only.', function (args, ctx) { + const cfg = configOf(ctx); + return { ok: true, enabled: !!(cfg.layerStack && cfg.layerStack.enabled), layers: (cfg.layers || []).map(publicLayer) }; + }); + tool('sv_list_audio_sources', null, 'List configured audio input sources. Read-only.', function (args, ctx) { + const audio = configOf(ctx).audio || {}; + return { ok: true, sources: audio.sources || [] }; + }); + tool('sv_get_analysis', null, 'Read live analysis metrics: key, chord, loudness, pitch, bands. Does not expose onset thresholds.', function (args, ctx) { + if (!ctx.analysis) return { ok: true, analysis: null }; + return Promise.resolve(ctx.analysis()).then(function (r) { + return r && r.ok === false ? r : { ok: true, analysis: r && r.analysis ? r.analysis : r }; + }); + }); + tool('sv_diagnose_audio', null, 'Read the audio capture diagnosis. Does not repair devices.', function (args, ctx) { + if (!ctx.diagnoseAudio) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return Promise.resolve(ctx.diagnoseAudio()).then(function (diagnostic) { + return { ok: true, diagnostic: diagnostic }; + }); + }); + tool('sv_set_audio_sources', 'output', 'Replace the audio input mix. Does not repair devices.', function (args, ctx) { + if (!args || !Array.isArray(args.sources)) return fail('sources array is required.'); + return withConfig(ctx, function (cfg) { + cfg.audio = Object.assign({}, cfg.audio, { sources: args.sources }); + return { count: args.sources.length }; + }); + }); + tool('sv_set_mapping', 'output', 'Set projection mapping for one display: enable, corners, crop, edge blend, masks. Does not open a new network bind.', function (args, ctx) { + const id = args && (args.displayId || args.id); + if (!id) return fail('displayId is required.'); + return withConfig(ctx, function (cfg) { + cfg.mapping = Object.assign({ enabled: false, outputs: {} }, cfg.mapping); + cfg.mapping.enabled = true; + const prev = cfg.mapping.outputs[id] || {}; + const next = Object.assign({}, prev); + ['enabled', 'corners', 'crop', 'edges', 'masks', 'testPattern', 'mesh', 'color'].forEach(function (k) { + if (args[k] !== undefined) next[k] = args[k]; + }); + cfg.mapping.outputs[id] = next; + return { displayId: id }; + }); + }); + tool('sv_repair_audio', 'repairAudio', 'Run audio repair. Requires Everything. Does not open a system dialog.', function (args, ctx) { + if (!ctx.repairAudio) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return Promise.resolve(ctx.repairAudio()).then(function (r) { + if (r && r.requiresManualAction) return fail(text('mcp.err.repairManual', localeOf(ctx))); + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + tool('sv_rotate_stream_token', 'streamToken', 'Rotate the OBS or remote stream token. Requires Everything. Does not change the bind address.', function (args, ctx) { + if (!ctx.newStreamToken) return fail(text('mcp.err.unavailable', localeOf(ctx))); + const which = args && args.which === 'remote' ? 'remoteToken' : 'token'; + const token = ctx.newStreamToken(); + if (!token) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return withConfig(ctx, function (cfg) { + cfg.stream = Object.assign({}, cfg.stream); + cfg.stream[which] = token; + return { which: which, rotated: true }; + }); + }); + tool('sv_updates_download', 'updates', 'Download an application update. Requires Everything.', function (args, ctx) { + if (!ctx.downloadUpdate) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return Promise.resolve(ctx.downloadUpdate()).then(function (r) { + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + tool('sv_updates_install', 'updates', 'Install a downloaded application update and restart. Requires Everything.', function (args, ctx) { + if (!ctx.installUpdate) return fail(text('mcp.err.unavailable', localeOf(ctx))); + return Promise.resolve(ctx.installUpdate()).then(function (r) { + return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); + }); + }); + + function callTool(name, args, ctx) { + let toolDef = null; + for (let i = 0; i < TOOLS.length; i++) if (TOOLS[i].name === name) toolDef = TOOLS[i]; + if (!toolDef) return fail('Unknown tool: ' + name); + const mcp = normalizeMcp(configOf(ctx).mcp); + const loc = localeOf(ctx); + const need = minMode(toolDef.group) || 'read'; + if (!mcp.enabled) return fail(text('mcp.err.disabled', loc).replace('{mode}', modeWord(need, loc))); + if (toolDef.group && toolDef.group !== 'routed' && !modeAllows(mcp, toolDef.group)) return fail(modeBlockError(loc, need)); + try { + const result = toolDef.fn(args || {}, ctx || {}); + if (result && typeof result.then === 'function') { + return result.then(function (value) { return value || { ok: true }; }, function (e) { return fail(String((e && e.message) || e)); }); + } + return result || { ok: true }; + } catch (e) { + return fail(String((e && e.message) || e)); + } + } + function publicTool(t, locale) { + const loc = locale === 'tr' ? 'tr' : 'en'; + const key = 'mcp.tool.' + t.name; + let body = text(key, loc); + if (!body || body === key) body = t.description; + const need = minMode(t.group) || 'read'; + const perm = !t.group ? text('mcp.perm.read', loc) + : (t.group === 'routed' ? text('mcp.perm.routed', loc) + : text('mcp.perm.needs', loc).replace('{mode}', modeWord(need, loc))); + return { name: t.name, description: body + ' ' + perm, inputSchema: t.inputSchema }; + } + function handleRpc(message, ctx) { + const msg = message || {}; + if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') { + return Promise.resolve({ jsonrpc: '2.0', id: msg.id == null ? null : msg.id, error: { code: -32600, message: 'Invalid Request' } }); + } + if (msg.id == null && String(msg.method).indexOf('notifications/') === 0) return Promise.resolve(null); + const id = msg.id; + if (msg.method === 'initialize') { + const requested = msg.params && msg.params.protocolVersion; + const protocolVersion = PROTOCOLS.indexOf(requested) >= 0 ? requested : '2024-11-05'; + return Promise.resolve({ + jsonrpc: '2.0', id: id, + result: { protocolVersion: protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'soundvisualizer', version: '3.1.5-beta' } }, + }); + } + if (msg.method === 'ping') return Promise.resolve({ jsonrpc: '2.0', id: id, result: {} }); + if (msg.method === 'tools/list') { + const loc = localeOf(ctx); + return Promise.resolve({ jsonrpc: '2.0', id: id, result: { tools: TOOLS.map(function (t) { return publicTool(t, loc); }) } }); + } + if (msg.method === 'tools/call') { + const params = msg.params || {}; + return Promise.resolve(callTool(params.name, params.arguments || {}, ctx)).then(function (out) { + return { jsonrpc: '2.0', id: id, result: { content: [{ type: 'text', text: JSON.stringify(out) }], isError: !out || out.ok === false } }; + }); + } + return Promise.resolve({ jsonrpc: '2.0', id: id, error: { code: -32601, message: 'Method not found: ' + msg.method } }); + } + function cardModel(cfg) { + const mcp = normalizeMcp(cfg && cfg.mcp); + return { + enabled: mcp.enabled, + mode: mcp.mode, + port: mcp.port, + setupVisible: mcp.enabled === true, + modes: MODES.slice(), + }; + } + function commandBundle(scriptPath, port) { + const script = scriptPath || 'mcp-stdio.js'; + const args = [script]; + const httpPort = normalizePort(port); + const url = 'http://127.0.0.1:' + httpPort + '/mcp'; + const json = JSON.stringify({ mcpServers: { soundvisualizer: { command: 'node', args: args } } }, null, 2); + const tomlArg = String(script).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const toml = '[mcp_servers.soundvisualizer]\ncommand = "node"\nargs = ["' + tomlArg + '"]\nenabled = true\n'; + return { command: 'node', args: args, json: json, toml: toml, shell: 'node ' + JSON.stringify(script), script: script, port: httpPort, url: url }; + } + + function installPrompt(scriptPath, locale, port) { + const bundle = commandBundle(scriptPath, port); + const loc = locale === "tr" ? "tr" : "en"; + return text("mcp.setup.prompt", loc) + .split("{command}").join(bundle.shell) + .split("{port}").join(String(bundle.port)) + .split("{url}").join(bundle.url); + } + function hostPlatform(explicit) { + if (explicit === 'win32' || explicit === 'darwin' || explicit === 'linux') return explicit; + /* Panel SV_PLATFORM kullanır. Süreç önce bakılırsa testteki ya da + gömülü Node'daki platform, açık olan işletim sistemini ezer. */ + try { + if (typeof window !== 'undefined' && window.SV_PLATFORM) { + if (window.SV_PLATFORM.isMac) return 'darwin'; + if (window.SV_PLATFORM.isLinux) return 'linux'; + if (window.SV_PLATFORM.isWindows) return 'win32'; + } + } catch (e) { /* yok */ } + if (typeof process !== 'undefined' && (process.platform === 'win32' || process.platform === 'darwin' || process.platform === 'linux')) { + return process.platform; + } + return 'linux'; + } + function clientFileStep(id, platform) { + const p = hostPlatform(platform); + if (id === 'claude') { + if (p === 'darwin') return 'macOS’ta ~/Library/Application Support/Claude/claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.'; + if (p === 'linux') return 'Linux’ta ~/.config/Claude/claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.'; + return 'Windows’ta %APPDATA%\\Claude\\claude_desktop_config.json dosyasına mcpServers bloğunu ekleyin.'; + } + if (id === 'codex') { + if (p === 'win32') return 'Tablo %USERPROFILE%\\.codex\\config.toml dosyasına eklenir. codex mcp add de aynı yere yazar.'; + return 'Tablo ~/.codex/config.toml dosyasına eklenir. codex mcp add de aynı yere yazar.'; + } + if (id === 'cursor') { + if (p === 'win32') return 'Proje için .cursor/mcp.json, genel için %USERPROFILE%\\.cursor\\mcp.json kullanın.'; + return 'Proje için .cursor/mcp.json, genel için ~/.cursor/mcp.json kullanın.'; + } + return ''; + } + function clients(scriptPath, port, platform) { + const httpPort = normalizePort(port); + const bundle = commandBundle(scriptPath, httpPort); + const osName = hostPlatform(platform); + return [ + { id: 'claude', title: 'Claude', platform: osName, steps: ['MCP açıkken bu uygulamayı açık tutun.', clientFileStep('claude', osName), 'Claude Desktop’u tamamen kapatıp yeniden açın.'], snippet: bundle.json }, + { id: 'codex', title: 'Codex', platform: osName, steps: ['MCP açıkken bu uygulamayı açık tutun.', clientFileStep('codex', osName), 'Yeni bir Codex oturumu açın.'], snippet: bundle.toml }, + { id: 'cursor', title: 'Cursor', platform: osName, steps: ['MCP açıkken bu uygulamayı açık tutun.', clientFileStep('cursor', osName), 'Cursor MCP listesini yenileyin.'], snippet: bundle.json }, + { id: 'grok', title: 'Grok', steps: ['Grok için yayınlanmış tek bir MCP ayar dosyası yok.', 'Stdio kabul eden istemcide aşağıdaki komutu kullanın.', 'mcpServers JSON’unu o istemcinin MCP listesine yapıştırın.'], snippet: bundle.shell + '\n\n' + bundle.json }, + { id: 'grok-bot', title: 'Grok Bot', steps: ['Grok Bot yerel bir mcp.json yolu yayınlamıyor.', 'Aynı stdio komutunu MCP sunucusu olarak ekleyin.', 'Aşağıdaki mcpServers bloğu geçerlidir. Ayrı bir protokol yok.'], snippet: bundle.shell + '\n\n' + bundle.json }, + { id: 'ollama', title: 'Ollama', steps: ['Ollama ayrı bir MCP protokolü değildir. Ücretsiz yerel model, aynı MCP sunucusuna bağlanan bir istemcidir.', 'Ollama’yı kurun, bir model çekin ve yerelde ollama serve çalışsın.', 'MCP konuşan istemcide modeli Ollama’ya yöneltin ve bu stdio sunucusunu ekleyin.', 'Aşağıdaki blok bu sunucudur.'], snippet: bundle.json }, + ]; + } + const api = { + ALLOW_KEYS: ALLOW_KEYS, DEFAULT_MCP: DEFAULT_MCP, GROUP_LABEL: GROUP_LABEL, + SCENE_KEYS: SCENE_KEYS, EFFECT_TYPES: EFFECT_TYPES, + normalizeMcp: normalizeMcp, groupForPath: groupForPath, visualState: visualState, + cardModel: cardModel, commandBundle: commandBundle, clients: clients, installPrompt: installPrompt, + hostPlatform: hostPlatform, clientFileStep: clientFileStep, + tools: function (locale) { return TOOLS.map(function (t) { return publicTool(t, locale || 'en'); }); }, + MODES: MODES, DEFAULT_PORT: DEFAULT_PORT, + callTool: callTool, handleRpc: handleRpc, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (typeof window !== 'undefined') window.SVMcp = api; +})(); diff --git a/src/shared/milkdrop.js b/src/shared/milkdrop.js index 18911b0..3e45baf 100644 --- a/src/shared/milkdrop.js +++ b/src/shared/milkdrop.js @@ -3098,7 +3098,18 @@ }; } - const api = { tokenize, parse, compile, Pool, FUNCS, parseMilk, Preset, parseMilk2, + /* Aynı uzunluktaki iki kaynak da farklıdır. Motor anahtarı yalnız + uzunluğa bakınca MCP'nin yerine yazdığı preset ekranda kalıyordu. + Aynı dizgi referansı her karede yeniden özetlenmesin diye motor + sonucu kendisi tutuyor. */ + function sourceTag(src) { + const s = src || ''; + let h = 2166136261; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + return (h >>> 0).toString(36) + ':' + s.length; + } + + const api = { tokenize, parse, compile, Pool, FUNCS, parseMilk, Preset, parseMilk2, sourceTag, clampColor, colorNorm, borderRings, md2Versions, stagePlan, genWarpText, genCompText, md3Features, md3Auto, isMd3, echoFlipBits, fixedCompWeights, parseMilkMd2, readMilk, readVersions, readingsDiffer, resetGlobals, diff --git a/src/visualizer/index.html b/src/visualizer/index.html index 1465e9a..f18bfb4 100644 --- a/src/visualizer/index.html +++ b/src/visualizer/index.html @@ -84,6 +84,7 @@ + diff --git a/src/visualizer/layers.js b/src/visualizer/layers.js index 42b1457..fc356a8 100644 --- a/src/visualizer/layers.js +++ b/src/visualizer/layers.js @@ -394,6 +394,69 @@ } } + /* Canlı görselleştirici. + Yığın açıkken resolve() yalnız cfg.layers'a bakar. Studio ve MCP + cfg.visualizer.type yazınca liste, çıkış ve önizleme eski katmanda + kalıyordu; düzeltme paneli kapatıp açınca ya da uygulamayı yeniden + başlatınca geliyordu. Aynı değişiklik ilk görselleştirici katmanına + da yazılır ve katman hemen açılır. Yığın kapalıyken ve liste boşken + klasik alan yeter; sentez onu çizer. + */ + const OVERLAY_VIS = { text: 1, nowplaying: 1 }; + function liveVisualizerLayers(cfg) { + const list = cfg && Array.isArray(cfg.layers) ? cfg.layers : []; + return list.filter((l) => l && l.kind === 'visualizer' && !OVERLAY_VIS[l.type]); + } + function adoptVisualizer(cfg, spec) { + if (!cfg) return null; + const s = spec || {}; + const type = s.type ? String(s.type) : ((cfg.visualizer && cfg.visualizer.type) || 'bars'); + const hasPreset = Object.prototype.hasOwnProperty.call(s, 'presetId'); + const presetId = hasPreset ? s.presetId : (cfg.custom && cfg.custom.visualizerId) || null; + cfg.visualizer = Object.assign({}, cfg.visualizer, { type: type }); + if (type === 'custom') { + cfg.custom = Object.assign({}, cfg.custom); + if (presetId) cfg.custom.visualizerId = presetId; + } + const hadLayers = Array.isArray(cfg.layers) && cfg.layers.length > 0; + if (!stackOn(cfg) && !hadLayers) return null; + if (!stackOn(cfg)) setStackEnabled(cfg, true); + if (!Array.isArray(cfg.layers)) cfg.layers = []; + let layer = liveVisualizerLayers(cfg)[0]; + if (!layer) { + layer = normalizeLayer({ + name: 'Görselleştirici', + kind: 'visualizer', + type: type, + presetId: type === 'custom' ? (presetId || null) : null, + enabled: true, + settings: s.visualizer ? { visualizer: JSON.parse(JSON.stringify(s.visualizer)) } : {}, + }); + cfg.layers.push(layer); + return layer; + } + layer.type = type; + if (type === 'custom') layer.presetId = presetId || layer.presetId || null; + else if (hasPreset) layer.presetId = presetId; + layer.enabled = true; + layer.muted = false; + if (s.visualizer) { + layer.settings = layer.settings || {}; + const prev = layer.settings.visualizer || {}; + layer.settings.visualizer = Object.assign({}, prev, JSON.parse(JSON.stringify(s.visualizer)), { type: type }); + } else if (layer.settings && layer.settings.visualizer) { + layer.settings.visualizer.type = type; + } + return layer; + } + /* Katmanı göstermek, bayrağı yazmak değildir. Yığın kapalıyken resolve() + listeyi kullanmaz; anahtar açılır, sessiz kalkar, katman çizilir. */ + function revealLayer(cfg, layer) { + if (!cfg || !layer) return; + if (!stackOn(cfg)) setStackEnabled(cfg, true); + layer.enabled = true; + layer.muted = false; + } function syncStackState(cfg) { if (!cfg || !cfg.layerStack || !cfg.layerStack.enabled) return; const curVis = cfg.visualizer && cfg.visualizer.type; @@ -525,8 +588,11 @@ const npSettings = (layer.settings && layer.settings.nowplaying) || {}; const defNp = (cfg && cfg.nowplaying) || (def ? def.nowplaying : {}); const baseVis = (cfg && cfg.visualizer) || (def ? def.visualizer : {}) || {}; + const visSettings = (layer.settings && layer.settings.visualizer) || {}; + const vis = Object.assign({}, baseVis, { type: 'nowplaying' }); + if (visSettings.glow != null) vis.glow = visSettings.glow; return Object.assign({}, base, { - visualizer: Object.assign({}, baseVis, { type: 'nowplaying' }), + visualizer: vis, nowplaying: Object.assign({}, defNp, base.nowplaying, npSettings, { enabled: layer.enabled !== false }), }); } @@ -835,19 +901,17 @@ const cr = Math.max(0, Math.min(0.5, lg.cornerRadius == null ? 0 : lg.cornerRadius)); const rad = cr > 0.0001 ? cr * Math.min(w, h) : 0; const glowBlur = (lg.glow && lg.glow > 0) ? lg.glow * 40 * (minDim / 1080) : 0; - /* clip+shadowBlur oval kenar ışığını keser; siluet ara tuvalde kurulur. */ + /* Beyaz shadowBlur kareyi perde gibi kaldırır. Parlama, opak kenarın dışında lighter hale olarak basılır. */ if (window.SVRoundImage && window.SVRoundImage.drawImage) { window.SVRoundImage.drawImage(ctx, drawable.source, x - w / 2, y - h / 2, w, h, { radiusPx: rad, glowBlur, - shadowColor: 'rgba(255,255,255,0.7)', + glowAmount: lg.glow, + edgeBloom: true, owner: this, }); } else { - if (glowBlur > 0) { - ctx.shadowColor = 'rgba(255,255,255,0.7)'; - ctx.shadowBlur = glowBlur; - } + /* SVRoundImage yoksa beyaz shadowBlur yine perde basar; parlamayı atla. */ if (rad > 0) { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x - w / 2, y - h / 2, w, h, rad); @@ -1926,6 +1990,8 @@ resolve, stackOn, setStackEnabled, + adoptVisualizer, + revealLayer, syncStackState, layerConfig, sceneSignature, diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index a5c3119..8b90eac 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -2080,7 +2080,21 @@ void main(){ /* ELLE SEÇİM OTOMATİĞİ EZER. Kullanıcı listeden bir preset seçtiğinde ayardaki kimlik değişiyor; o an otomatik seçim bırakılıyor ve sayaç sıfırlanıyor, yoksa seçtiği preset bir sonraki tik'e kadar bile - durmayabilirdi. */ + durmayabilirdi. + Anahtar uzunluğa ek olarak kaynağın özetini de taşır: aynı + uzunlukta yeni bir metin (MCP kaydı) ekranda yenilensin. Tohum + hâlâ kimlik ve uzunluktan gelir; özet değişince rastgelelik + sıçramasın. */ + const MD = window.SVMilkdrop; + const tagOf = (slot, text) => { + const t = text || ''; + const srcKey = slot + 'Src'; + const tagKey = slot + 'Tag'; + if (this[srcKey] === t) return this[tagKey]; + this[srcKey] = t; + this[tagKey] = (MD && MD.sourceTag) ? MD.sourceTag(t) : String(t.length); + return this[tagKey]; + }; const man = (c.presetId || '') + '|' + (c.source || '').length; if (man !== this._manualKey) { this._manualKey = man; @@ -2099,14 +2113,15 @@ void main(){ a.source = s; a.lazy = false; } - const baseKey = a ? (a.id + '|' + a.source.length) : man; + const baseKey = a + ? (a.id + '|' + (a.source || '').length + '#' + tagOf('a', a.source)) + : (man + '#' + tagOf('m', c.source)); /* MILKDROP 3 ÇİFT PRESETİ (.milk2, #567): iki aşamada yükleniyor. 1. aşama birinci preseti sert geçişle kuruyor; 2. aşama ikinciye GEÇİŞ başlatıyor ve geçiş dosyanın söylediği yerde donduruluyor. Böylece iki presetin derlenmesi, iki saat ve düğüm başına karışım olağan geçişin aynı yolundan geçiyor. İkinci aşama birincinin shader'ları hazır olunca (bir sonraki kare) başlıyor. */ - const MD = window.SVMilkdrop; const rawSrc = (a ? a.source : c.source) || defaultSource(); const dbl = MD && MD.parseMilk2 ? MD.parseMilk2(rawSrc) : null; if (dbl) { @@ -2158,7 +2173,8 @@ void main(){ : cutNow ? 0 : (this._reduced ? BLEND_MAX : Math.max(0, Math.min(BLEND_MAX, want))); /* Değişimin tohumu (#585): otomatik seçimde seçenin (lider pencere ya da izlenen) verdiği, elle seçimde seçimin kendisinden. */ - const seed = a && Number.isInteger(a.seed) ? a.seed >>> 0 : hashSeed(key); + const seedIdent = a ? (a.id + '|' + (a.source || '').length) : ((c.presetId || '') + '|' + (c.source || '').length); + const seed = a && Number.isInteger(a.seed) ? a.seed >>> 0 : hashSeed(stage === 1 ? seedIdent + '#1' : seedIdent); const src = stage ? this._double.info.presets[stage - 1] : rawSrc; /* YENİ PRESET HAZIR OLANA KADAR ESKİSİ SÜRÜYOR (#573). @@ -2291,7 +2307,14 @@ void main(){ if (s === null) return; p = Object.assign({}, p, { source: s }); } - const key = p.id + '|' + (p.source || '').length; + const text = p.source || ''; + let tag = this._preTag; + if (this._preSrc !== text) { + const MD = window.SVMilkdrop; + this._preSrc = text; + tag = this._preTag = (MD && MD.sourceTag) ? MD.sourceTag(text) : String(text.length); + } + const key = p.id + '|' + text.length + '#' + tag; if (key === this.presetKey) return; const P = this._pending; if (P && (!P.early || P.key === key)) return; diff --git a/src/visualizer/modes/nowplaying.js b/src/visualizer/modes/nowplaying.js index 1bd6e83..bf11f37 100644 --- a/src/visualizer/modes/nowplaying.js +++ b/src/visualizer/modes/nowplaying.js @@ -69,10 +69,12 @@ const c = cfg.nowplaying || {}; if ((c.source || 'system') === 'manual') { const m = c.manual || {}; - const any = !!(m.title || m.artist || m.album); + const dur = Math.max(0, Number(m.duration) || 0); + const any = !!(m.title || m.artist || m.album || dur > 0); return Object.assign({}, N.EMPTY, { - has: any, playing: any, + has: any, playing: false, title: m.title || '', artist: m.artist || '', album: m.album || '', + duration: dur, }); } return (window.SVNowLive && window.SVNowLive.state) || N.EMPTY; @@ -107,12 +109,15 @@ const style = N.styleOf(c.style); const pick = (v, k) => (v === null || v === undefined ? style[k] : v); - /* Süre, çubuk ve oynatıcı adı yalnız Windows medya oturumundan gelir. - Elle yazılan parçada, ya da macOS/Linux'ta, 0:00 göstermek yanlış bilgi olur. */ + /* Geçen süre, kalan süre, çubuk ve oynatıcı adı sistem oturumundan gelir. + macOS/Linux bunu okuyamaz. Elle yazılan toplam süre gösterilir; + geçen süre uydurulmaz. */ const platWin = !(typeof window !== 'undefined' && window.SV_PLATFORM && window.SV_PLATFORM.isWindows === false); const fromSystem = platWin && (c.source || 'system') === 'system'; + const manualDur = Math.max(0, Number((c.manual && c.manual.duration) || 0)); const show = fromSystem ? c.show : Object.assign({}, c.show, { - appName: false, elapsed: false, remaining: false, total: false, bar: false, + appName: false, elapsed: false, remaining: false, bar: false, + total: manualDur > 0, }); const parts = N.compose(st, Object.assign({}, c, { show, @@ -300,14 +305,16 @@ ctx.scale(1 / pulse, 1 / pulse); ctx.translate(coverCx, coverCy); ctx.scale(coverPulse, coverPulse); - /* Kapak da logo/görsel gibi yuvarlatılınca dış ışık clip ile kesilmesin. - Metin gölgesi (shadow) varsa aynı yumuşak dış ışığı oval kenara taşı. */ - const coverGlow = shadow > 0 ? shadow * minCover * 0.22 : 0; + /* Kapak parlaması logo ile aynı kenar halesi. Slider visualizer.glow. + Metin gölgesi kapağa shadowBlur basmaz. */ + const glowAmt = Math.max(0, Number(cfg.visualizer && cfg.visualizer.glow) || 0); + const coverGlow = glowAmt > 0 ? glowAmt * 40 * (minDim / 1080) : 0; if (window.SVRoundImage && window.SVRoundImage.drawImage) { window.SVRoundImage.drawImage(ctx, coverImg, -coverW / 2, -coverH / 2, coverW, coverH, { radiusPx: rad, glowBlur: coverGlow, - shadowColor: 'rgba(0,0,0,0.55)', + glowAmount: glowAmt, + edgeBloom: true, owner: this, fit: drawFit, }); diff --git a/src/visualizer/modes/round-image.js b/src/visualizer/modes/round-image.js index c7b4914..f00c897 100644 --- a/src/visualizer/modes/round-image.js +++ b/src/visualizer/modes/round-image.js @@ -4,19 +4,26 @@ clip() + shadowBlur birlikte kullanılınca gölge kırpma bölgesinin dışında kalır ve oval/yuvarlak kenarın dış ışığı kaybolur. Çözüm: önce kırpılmış silueti ara tuvale çiz, sonra onu shadowBlur ile bas — gölge alfa - siluetini (yuvarlatılmış kenarı) takip eder. */ + siluetini (yuvarlatılmış kenarı) takip eder. + + Logo parlaması (edgeBloom) bu beyaz shadowBlur yolunu KULLANMAZ. + shadowColor beyaz + source-over, şeklin kenarına ışık eklemek yerine + tüm kareyi beyaz/gri bir perdeyle kaldırır (slider ~%32'de belirgin). + Siluet bulanıklaştırılır, opak gövde delinir, hale 'lighter' ile eklenir. */ (function () { - function ensureScratch(owner, w, h) { + function ensureScratch(owner, w, h, slot) { const sw = Math.max(1, Math.ceil(w)); const sh = Math.max(1, Math.ceil(h)); - if (!owner._svRoundScratch) { - owner._svRoundScratch = { - canvas: document.createElement('canvas'), - ctx: null, + const key = slot || '_svRoundScratch'; + const host = owner || ensureScratch; + if (!host[key]) { + const canvas = document.createElement('canvas'); + host[key] = { + canvas: canvas, + ctx: canvas.getContext('2d'), }; - owner._svRoundScratch.ctx = owner._svRoundScratch.canvas.getContext('2d'); } - const sc = owner._svRoundScratch; + const sc = host[key]; if (sc.canvas.width !== sw || sc.canvas.height !== sh) { sc.canvas.width = sw; sc.canvas.height = sh; @@ -24,6 +31,35 @@ return sc; } + /* Blur çekirdeği sprite dikdörtgeninin dışına taşar. Pad, halenin + tuval kenarında kırpılıp düz bir perde gibi görünmesini engeller. + knockout: opak gövde haleden silinir. + composite: hale zemine ışık ekler, beyaz source-over perde değil. */ + function edgeBloomLayout(glowBlur, amount) { + const input = Math.max(0, Number(glowBlur) || 0); + /* Girdi 1080p'de slider*40. %40'a kadar hale dar kalinca yigilip + topak oluyor; %80'den sonra saçilan görünüm doğru. + Ease-out: %10'da okunur, %40'ta saçık, %100'de 120px * çözünürlük. + Şiddet 0'da kapalı, %100'de 3. İkisi de sürekli; beyaz perde yok. + amount slider 0..1; verilmezse girdi 1080p sayılır. */ + let u = Number(amount); + if (!Number.isFinite(u)) u = input > 0 ? input / 40 : 0; + if (u < 0) u = 0; + else if (u > 1) u = 1; + const res = u > 0.0001 ? input / (40 * u) : 1; + const spread = u * (2 - u); + const glow = 120 * res * spread; + const strength = 3 * spread; + const pad = Math.max(2, Math.ceil(glow * 3)); + return { + glow: glow, + strength: strength, + pad: pad, + knockout: 'destination-out', + composite: 'lighter', + }; + } + /* Source'u (dx,dy,dw,dh) hedefine fit'e göre basar. fit: 'stretch' (varsayılan) | 'cover' | 'contain' */ function drawFitted(ctx, source, dx, dy, dw, dh, fit) { @@ -43,10 +79,72 @@ const oy = dy + (dh - rh) / 2; ctx.drawImage(source, ox, oy, rw, rh); } + + function drawEdgeBloom(ctx, source, x, y, w, h, rad, glow, fit, owner, amount) { + const layout = edgeBloomLayout(glow, amount); + const pad = layout.pad; + const sw = w + pad * 2; + const sh = h + pad * 2; + const host = owner || drawImage; + const sc = ensureScratch(host, sw, sh, '_svBloomSharp'); + const s = sc.ctx; + s.setTransform(1, 0, 0, 1, 0, 0); + s.globalAlpha = 1; + s.globalCompositeOperation = 'source-over'; + s.filter = 'none'; + s.shadowBlur = 0; + s.clearRect(0, 0, sc.canvas.width, sc.canvas.height); + s.save(); + if (rad > 0.0001) { + s.beginPath(); + const rr = Math.min(rad, Math.min(w, h) / 2); + if (s.roundRect) s.roundRect(pad, pad, w, h, rr); + else s.rect(pad, pad, w, h); + s.clip(); + } + drawFitted(s, source, pad, pad, w, h, fit); + s.restore(); + + const bc = ensureScratch(host, sw, sh, '_svBloomBlur'); + const b = bc.ctx; + b.setTransform(1, 0, 0, 1, 0, 0); + b.globalAlpha = 1; + b.globalCompositeOperation = 'source-over'; + b.filter = 'none'; + b.shadowBlur = 0; + b.clearRect(0, 0, bc.canvas.width, bc.canvas.height); + b.filter = 'blur(' + layout.glow.toFixed(2) + 'px)'; + b.drawImage(sc.canvas, 0, 0); + b.filter = 'none'; + /* Opak gövdeyi del: hale yalnızca kenarın dışında kalsın, gövde yıkanmasın. */ + b.globalCompositeOperation = layout.knockout; + b.drawImage(sc.canvas, 0, 0); + b.globalCompositeOperation = 'source-over'; + + const prevOp = ctx.globalCompositeOperation; + const prevAlpha = ctx.globalAlpha; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.globalCompositeOperation = layout.composite; + const strength = layout.strength; + const whole = Math.floor(strength); + const frac = strength - whole; + for (let n = 0; n < whole; n++) ctx.drawImage(bc.canvas, x - pad, y - pad); + if (frac > 0.0001) { + ctx.globalAlpha = prevAlpha * frac; + ctx.drawImage(bc.canvas, x - pad, y - pad); + ctx.globalAlpha = prevAlpha; + } + ctx.globalCompositeOperation = prevOp; + ctx.drawImage(sc.canvas, pad, pad, w, h, x, y, w, h); + } + /* ctx üzerine source'u (x,y,w,h) basar. opts.radiusPx: köşe yarıçapı piksel opts.glowBlur: shadowBlur yarıçapı (0 = parlama yok) - opts.shadowColor: gölge rengi + opts.shadowColor: gölge rengi (edgeBloom dışı; drop shadow) + opts.edgeBloom: beyaz perde yerine renkli kenar halesi opts.owner: scratch tuvalini tutan nesne (yeniden kullanım) */ function drawImage(ctx, source, x, y, w, h, opts) { const o = opts || {}; @@ -55,6 +153,11 @@ const color = o.shadowColor || 'rgba(255,255,255,0.7)'; const fit = o.fit || 'stretch'; + if (glow > 0 && o.edgeBloom) { + drawEdgeBloom(ctx, source, x, y, w, h, rad, glow, fit, o.owner || drawImage, o.glowAmount); + return; + } + if (rad <= 0.0001) { if (glow > 0) { ctx.shadowColor = color; @@ -101,7 +204,7 @@ } } - const api = { drawImage, drawFitted, ensureScratch }; + const api = { drawImage, drawFitted, ensureScratch, edgeBloomLayout, drawEdgeBloom }; if (typeof window !== 'undefined') window.SVRoundImage = api; if (typeof module !== 'undefined' && module.exports) module.exports = api; })(); diff --git a/src/visualizer/visualizer.js b/src/visualizer/visualizer.js index 8da8d84..e0a052e 100644 --- a/src/visualizer/visualizer.js +++ b/src/visualizer/visualizer.js @@ -584,34 +584,113 @@ // Başlat // -------------------------------------------------------------------------- async function init() { - // Studio presetleri (kullanıcının kendi shader'ları) ana süreçte tutulur + if (!window.api) { + showError('Başlatılamadı: pencere köprüsü yok'); + return; + } + // Studio presetleri (kullanıcının kendi shader'ları) ana süreçte tutulur. + // Dinleyici listeden ÖNCE kurulur: kayıt o sırada gelirse yayın düşmesin + // ve çıkış yeniden başlatılana kadar eski shader'da kalmasın. + let presetsReady = false; + const earlyDeltas = []; + let presetGen = 0; + let presetCatchBusy = false; + const onPresetDelta = (d) => { + const S = window.SVPresets; + const ups = (d && Array.isArray(d.upsert)) ? d.upsert : []; + const studio = ups.some((p) => p && p.kind !== 'milkdrop') || + (d && Array.isArray(d.remove) ? d.remove : []).some((id) => { const p = S.get(id); return !!p && p.kind !== 'milkdrop'; }); + let patched = false; + if (typeof cfg !== 'undefined' && cfg && cfg.milkdrop) { + for (let i = 0; i < ups.length; i++) { + const p = ups[i]; + if (p && p.kind === 'milkdrop' && p.id === cfg.milkdrop.presetId && typeof p.source === 'string' && p.source !== cfg.milkdrop.source) { + cfg.milkdrop = Object.assign({}, cfg.milkdrop, { source: p.source, name: p.name || cfg.milkdrop.name }); + patched = true; + break; + } + } + } + S.applyDelta(d); + if (studio) { + stack.dispose(); + applyScene(); + } else if (patched && typeof applyConfig === 'function') { + applyConfig(cfg); + } + }; + async function catchPresets() { + if (!window.api.presetsSince || presetCatchBusy) return; + presetCatchBusy = true; + try { + const page = await window.api.presetsSince(presetGen); + if (!page) return; + const nextGen = Number(page.gen) || 0; + if (page.reset || page.bulk) { + presetGen = nextGen; + window.SVPresets.setUser(await window.api.getPresets()); + stack.dispose(); + applyScene(); + return; + } + presetGen = nextGen; + const up = page.upsert || []; + const rm = page.remove || []; + if (!up.length && !rm.length) return; + onPresetDelta({ upsert: up, remove: rm, gen: nextGen }); + } catch { /* ana süreç kapandıysa bir sonraki tur dener */ } + finally { presetCatchBusy = false; } + } + if (window.api && window.api.onPresetsDelta) { + window.api.onPresetsDelta((d) => { + if (d && d.gen) presetGen = Math.max(presetGen, Number(d.gen) || 0); + if (!presetsReady) { earlyDeltas.push(d); return; } + onPresetDelta(d); + }); + } + let headGen = 0; + try { + if (window.api.presetsHead) { + const head = await window.api.presetsHead(); + headGen = head && Number(head.gen) || 0; + } + } catch { headGen = 0; } try { window.SVPresets.setUser(await window.api.getPresets()); } catch { /* preset yoksa yerleşiklerle devam */ } + presetsReady = true; + let maxEarly = 0; + for (let i = 0; i < earlyDeltas.length; i++) { + const d = earlyDeltas[i]; + if (d && d.gen) maxEarly = Math.max(maxEarly, Number(d.gen) || 0); + onPresetDelta(d); + } + try { + if (window.api.presetsSince) { + const page = await window.api.presetsSince(headGen); + if (page) { + presetGen = Math.max(maxEarly, Number(page.gen) || 0); + if (page.reset || page.bulk) { + try { window.SVPresets.setUser(await window.api.getPresets()); } catch { /* liste yok */ } + stack.dispose(); + applyScene(); + } else if ((page.upsert && page.upsert.length) || (page.remove && page.remove.length)) { + onPresetDelta({ upsert: page.upsert || [], remove: page.remove || [], gen: page.gen }); + } + } + } + } catch { /* yakalama yoksa yayın yeter */ } + presetGen = Math.max(presetGen, maxEarly); + setInterval(() => { + if (cfg && cfg.mcp && cfg.mcp.enabled) catchPresets(); + }, 400); + window.addEventListener('focus', () => { catchPresets(); }); window.api.onPresets((list) => { window.SVPresets.setUser(list); // Seçili preset düzenlendiyse motorun kaynağı yenilensin stack.dispose(); applyScene(); }); - /* DEĞİŞİKLİK YAYINI (#574): yalnız değişenler geliyor. Sahne yalnız bir - Studio preseti değişince yeniden kuruluyor; MilkDrop presetlerinin - listesi değişince (içe aktarım, silme) motor baştan başlamıyor — - çizdiği kaynak ayardan ya da seçimden geliyor, listeden değil. - Önceden her içe aktarım ekrandaki MilkDrop'u sıfırlıyordu. Silinen - presetin türü, liste güncellenmeden ÖNCE okunuyor. */ - if (window.api.onPresetsDelta) { - window.api.onPresetsDelta((d) => { - const S = window.SVPresets; - const studio = (d && Array.isArray(d.upsert) ? d.upsert : []).some((p) => p && p.kind !== 'milkdrop') || - (d && Array.isArray(d.remove) ? d.remove : []).some((id) => { const p = S.get(id); return !!p && p.kind !== 'milkdrop'; }); - S.applyDelta(d); - if (studio) { - stack.dispose(); - applyScene(); - } - }); - } const saved = await window.api.requestConfig(); if (saved) cfg = window.SV.deepMerge(window.SV.defaultConfig(), saved); @@ -640,8 +719,14 @@ if (window.api.milkdropSprite) spriteKeys(); } /* Çalan parça çıpası. Her kare gelmez — kaynak konumu ancak ara sıra - günceller — aradaki değeri katmanlar SVNowPlaying ile hesaplar. */ - if (window.api.onNowPlaying) { + günceller — aradaki değeri katmanlar SVNowPlaying ile hesaplar. + Müzik pencere açılmadan çalıyorsa ilk örnek bu satırdan önce + gitmiş olabilir. Preload son örneği saklar; burada bir de ana + sürecin güncel durumunu isteriz ve bu arada canlı mesaj geldiyse + onu ezmeyiz. */ + if (window.SVLateEvent) { + await window.SVLateEvent.catchUp(window.SVNowLive, window.api); + } else if (window.api.onNowPlaying) { window.api.onNowPlaying((st) => { window.SVNowLive.state = st; }); } window.addEventListener('resize', resize); diff --git a/src/web/overlay.html b/src/web/overlay.html index 7902833..ecd9c85 100644 --- a/src/web/overlay.html +++ b/src/web/overlay.html @@ -124,6 +124,7 @@ + diff --git a/tests/display-selection.test.js b/tests/display-selection.test.js new file mode 100644 index 0000000..bac338d --- /dev/null +++ b/tests/display-selection.test.js @@ -0,0 +1,46 @@ +'use strict'; +/* Boş ekran seçimi boş kalır. Eski kod liste boşalınca harici ya da + birincil ekranı geri seçiyordu; açılış da aynı listeyi birincile çeviriyordu. */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const admin = fs.readFileSync(path.join(root, 'src', 'admin', 'admin.js'), 'utf8'); +const main = fs.readFileSync(path.join(root, 'src', 'main', 'main.js'), 'utf8'); + +function resolveDisplayIds() { + const src = main.match(/function resolveDisplayIds\(input\) \{\r?\n[\s\S]*?\r?\n\}/); + assert.ok(src, 'resolveDisplayIds bulunamadı'); + return new Function('screen', src[0] + '\nreturn resolveDisplayIds;'); +} + +test('panel boş seçimi geri doldurmaz', () => { + assert.doesNotMatch(admin, /selectedDisplayIds = \[\(ext \|\| displays\[0\]\)\.id\]/); + assert.doesNotMatch(admin, /if \(!selectedDisplayIds\.length && displays\.length\)/); + assert.match(admin, /Boş liste boş kalır/); + assert.match(admin, /if \(!selectedDisplayIds\.length\) return 'Ekran seçilmedi'/); +}); + +test('boş veya bilinmeyen ekran listesi birincil ekrana düşmez', () => { + const fn = resolveDisplayIds()({ + getAllDisplays: () => [{ id: 1 }, { id: 2 }], + getPrimaryDisplay: () => { throw new Error('birincil ekrana düşmemeli'); }, + }); + assert.deepStrictEqual(fn([]), []); + assert.deepStrictEqual(fn(null), []); + assert.deepStrictEqual(fn(''), []); + assert.deepStrictEqual(fn([9]), []); + assert.deepStrictEqual(fn([2, 2, 9, 1]), [2, 1]); + assert.deepStrictEqual(fn(1), [1]); +}); + +test('açık görselleştirici boş seçimde istenmeyen ekranı kapatır', () => { + const body = main.slice(main.indexOf('function openVisualizer'), main.indexOf('function configuredSources')); + assert.match(body, /if \(wanted\.indexOf\(id\) === -1\) closeVisualizer\(id\)/); + assert.match(body, /for \(const id of wanted\)/); + /* Birincil ekran yalnız istenen kimlik listede yoksa yerleşim için. + Döngü boş wanted ile hiç girmez. */ + assert.match(body, /all\.find\(\(d\) => d\.id === id\) \|\| screen\.getPrimaryDisplay\(\)/); +}); diff --git a/tests/layers-panel-ui.test.js b/tests/layers-panel-ui.test.js index 2d4445d..c1d4d8f 100644 --- a/tests/layers-panel-ui.test.js +++ b/tests/layers-panel-ui.test.js @@ -222,4 +222,98 @@ test('open layer control gets data-sv-local-def and syncs modified on change', ( range.attrs.oninput({ target: { value: '1' } }); opacity._svSyncModified(); assert.strictEqual(modified, false, 'clears when back to default'); +}); + +function rowOf(root, label) { + return all(root, (n) => n.attrs && typeof n.attrs.class === 'string' && n.attrs.class.split(/\s+/).includes('row') + && (n.children || []).some((c) => c.attrs && c.attrs.text === label))[0]; +} + +function selectOf(row) { + return all(row, (n) => n.tag === 'select')[0]; +} + +function ctrlOf(root, label) { + return all(root, (n) => (n.children || []).some((c) => c.attrs + && typeof c.attrs.class === 'string' + && c.attrs.class.split(/\s+/).includes('row') + && (c.children || []).some((g) => g.attrs && g.attrs.text === label)))[0]; +} + +function rangeOf(root, label) { + return all(ctrlOf(root, label), (n) => n.tag === 'input' && n.attrs.type === 'range')[0]; +} + +function openTab(panel, label) { + const btn = all(panel, (n) => n.tag === 'button' && n.attrs && n.attrs.text === label + && typeof n.attrs.class === 'string' && n.attrs.class.split(/\s+/).includes('layer-tab'))[0]; + assert.ok(btn, 'sekme ' + label); + btn.attrs.onclick(); +} + +test('çalan parça katmanı hizalamayı ve ekranda kalma süresini yazar; linux süre alanlarını gizler', () => { + const prev = window.SV_PLATFORM; + window.SV_PLATFORM = { os: 'linux', isWindows: false, isMac: false, isLinux: true }; + try { + const { cfg } = setup([{ id: 'np', kind: 'nowplaying', type: 'nowplaying', name: 'Çalan' }], ['np']); + let panel = window.SVScenePanels.layersPanel(); + const align = rowOf(panel, 'Hizalama'); + assert.ok(align, 'hizalama satırı'); + selectOf(align).attrs.onchange({ target: { value: 'right' } }); + const np = cfg.layers[0].settings.nowplaying; + assert.strictEqual(np.align, 'right'); + assert.strictEqual(np.source, 'manual'); + assert.strictEqual(rowOf(panel, 'Geçen Süre'), undefined); + assert.strictEqual(rowOf(panel, 'İlerleme Çubuğu'), undefined); + selectOf(rowOf(panel, 'Ne Zaman')).attrs.onchange({ target: { value: 'onChange' } }); + panel = window.SVScenePanels.layersPanel(); + const hold = rowOf(panel, 'Ekranda Kalma'); + assert.ok(hold, 'ekranda kalma'); + rangeOf(panel, 'Ekranda Kalma').attrs.oninput({ target: { value: '6.5' } }); + assert.strictEqual(cfg.layers[0].settings.nowplaying.holdSeconds, 6.5); + assert.strictEqual(rowOf(panel, 'Yatay'), undefined, 'konum dönüşüm sekmesinde'); + assert.strictEqual(rowOf(panel, 'Geçen Süre'), undefined); + const sure = all(ctrlOf(panel, 'Süre'), (n) => n.tag === 'input')[0]; + assert.ok(sure, 'süre elle'); + sure.attrs.oninput({ target: { value: '3:24' } }); + assert.strictEqual(cfg.layers[0].settings.nowplaying.manual.duration, 204); + openTab(panel, 'Hareket'); + assert.ok(rowOf(panel, 'Giriş Süresi'), 'giriş süresi hareket sekmesinde'); + } finally { + window.SV_PLATFORM = prev; + } +}); + +test('çalan parça katmanı Windows’ta geçen süreyi ve çubuğu gösterir', () => { + const prev = window.SV_PLATFORM; + window.SV_PLATFORM = { os: 'win32', isWindows: true, isMac: false, isLinux: false }; + try { + setup([{ id: 'np', kind: 'nowplaying', type: 'nowplaying', name: 'Çalan' }], ['np']); + const panel = window.SVScenePanels.layersPanel(); + assert.strictEqual(rowOf(panel, 'Süre'), undefined, 'sistem süresi elle kutusu açmaz'); + assert.ok(rowOf(panel, 'Hizalama')); + openTab(panel, 'Yazı'); + assert.ok(rowOf(panel, 'Geçen Süre')); + assert.ok(rowOf(panel, 'İlerleme Çubuğu')); + } finally { + window.SV_PLATFORM = prev; + } +}); + +test('metin katmanı yatay konumu ve giriş süresini yazar', () => { + const { cfg } = setup([{ id: 'tx', kind: 'visualizer', type: 'text', name: 'Yazı' }], ['tx']); + const panel = window.SVScenePanels.layersPanel(); + assert.strictEqual(rowOf(panel, 'Yatay'), undefined, 'ikinci yatay kaydırıcı yok'); + assert.ok(rowOf(panel, 'Hizalama')); + const textX = cfg.layers[0].settings.text.x; + openTab(panel, 'Dönüşüm'); + rangeOf(panel, 'Yatay Konum').attrs.oninput({ target: { value: '0.2' } }); + assert.strictEqual(cfg.layers[0].transform.x, 0.2); + assert.strictEqual(cfg.layers[0].settings.text.x, textX); + openTab(panel, 'Hareket'); + assert.ok(rowOf(panel, 'Giriş Süresi')); + selectOf(rowOf(panel, 'Metin Kaynağı')).attrs.onchange({ target: { value: 'lyrics' } }); + const again = window.SVScenePanels.layersPanel(); + assert.ok(rowOf(again, 'Senkron Kayması')); + assert.ok(rowOf(again, 'Karaoke Vurgusu')); }); \ No newline at end of file diff --git a/tests/live-refresh.test.js b/tests/live-refresh.test.js new file mode 100644 index 0000000..7733915 --- /dev/null +++ b/tests/live-refresh.test.js @@ -0,0 +1,133 @@ +'use strict'; +/* Studio / MCP changes must hit the layer stack the output already draws, + and a preset saved while the store is still reading the folder must stay + in the list. Restarting the app was the only refresh. */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +/* CI does not install Electron. The preset store only needs app.getPath + until setDir points it at a temp folder. */ +const origLoad = Module._load; +Module._load = function (request) { + if (request === 'electron') return { app: { getPath: () => os.tmpdir() } }; + return origLoad.apply(this, arguments); +}; + +global.window = global.window || {}; +require('../src/shared/defaults.js'); +const L = require('../src/visualizer/layers.js'); +const mcp = require('../src/shared/mcp'); +const store = require('../src/main/presets-store'); + +function scene() { + return { + background: { type: 'gradient' }, + visualizer: { type: 'bars', color: '#fff' }, + custom: { visualizerId: null }, + media: { enabled: false }, + images: { enabled: false }, + logo: { enabled: false }, + text: { enabled: false }, + layers: [ + { id: 'ly_vis', kind: 'visualizer', type: 'bars', enabled: false, muted: true, presetId: null, settings: { visualizer: { type: 'bars' } } }, + ], + layerStack: { enabled: true }, + }; +} + +test('adoptVisualizer retargets the live layer instead of only the classic field', () => { + const cfg = scene(); + const layer = L.adoptVisualizer(cfg, { type: 'custom', presetId: 'usr_live' }); + assert.strictEqual(layer.type, 'custom'); + assert.strictEqual(layer.presetId, 'usr_live'); + assert.strictEqual(layer.enabled, true); + assert.strictEqual(layer.muted, false); + assert.strictEqual(layer.settings.visualizer.type, 'custom'); + const drawn = L.resolve(cfg).filter((l) => l.kind === 'visualizer'); + assert.strictEqual(drawn.length, 1); + assert.strictEqual(drawn[0].type, 'custom'); + assert.strictEqual(drawn[0].presetId, 'usr_live'); +}); + +test('revealLayer turns a hidden visualizer on when the stack was not driving', () => { + const cfg = scene(); + cfg.layerStack.enabled = false; + const layer = cfg.layers[0]; + L.revealLayer(cfg, layer); + assert.strictEqual(L.stackOn(cfg), true); + assert.strictEqual(layer.enabled, true); + assert.strictEqual(layer.muted, false); + assert.ok(L.resolve(cfg).some((l) => l.id === 'ly_vis' && l.type === 'bars')); +}); + +test('MCP visualizer type and layer enable update the stack that is drawn', async () => { + const cfg = scene(); + cfg.mcp = { enabled: true, mode: 'write' }; + const ctx = { + locale: function () { return 'en'; }, + getConfig: function () { return cfg; }, + setConfig: function (next) { + Object.keys(cfg).forEach(function (k) { delete cfg[k]; }); + Object.assign(cfg, next); + }, + }; + const typed = await mcp.callTool('sv_set_visualizer_type', { type: 'circle' }, ctx); + assert.strictEqual(typed.ok, true, JSON.stringify(typed)); + assert.strictEqual(cfg.layers[0].type, 'circle'); + assert.strictEqual(cfg.layers[0].enabled, true); + cfg.layers[0].enabled = false; + cfg.layers[0].muted = true; + const shown = await mcp.callTool('sv_set_layer_enabled', { id: 'ly_vis', enabled: true }, ctx); + assert.strictEqual(shown.ok, true, JSON.stringify(shown)); + assert.strictEqual(cfg.layers[0].enabled, true); + assert.strictEqual(cfg.layers[0].muted, false); + assert.strictEqual(L.stackOn(cfg), true); + assert.ok(L.resolve(cfg).some((l) => l.type === 'circle')); +}); + +test('sv_save_preset without a kind stores a shader as a Studio visualizer', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-presets-')); + store.setDir(dir); + let captured = null; + const ctx = { + locale: function () { return 'en'; }, + getConfig: function () { return { mcp: { enabled: true, mode: 'write' } }; }, + presets: { + save: function (preset) { + captured = preset; + return store.save(preset); + }, + }, + }; + const shader = await mcp.callTool('sv_save_preset', { name: 'Glow', shader: 'void mainImage(){}' }, ctx); + assert.strictEqual(shader.ok, true, JSON.stringify(shader)); + assert.strictEqual(captured.kind, 'visualizer'); + assert.strictEqual(captured.engine, 'shader'); + const milk = await mcp.callTool('sv_save_preset', { name: 'Warp', source: 'zoom=1' }, ctx); + assert.strictEqual(milk.ok, true, JSON.stringify(milk)); + assert.strictEqual(captured.kind, 'milkdrop'); + const explicit = await mcp.callTool('sv_save_preset', { name: 'Bg', kind: 'background', engine: 'shader', shader: 'void main(){}' }, ctx); + assert.strictEqual(explicit.ok, true, JSON.stringify(explicit)); + assert.strictEqual(captured.kind, 'background'); + assert.strictEqual(captured.engine, 'shader'); + store.setDir(null); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('a preset saved while the folder is still warming is in the list', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-presets-')); + store.setDir(dir); + const warming = store.warm(); + const saved = store.save({ id: 'usr_warm', name: 'Warm', kind: 'visualizer', engine: 'shader', shader: 'void main(){}' }); + assert.strictEqual(saved.ok, true); + await warming; + const ids = store.list().map((p) => p.id); + assert.ok(ids.indexOf('usr_warm') >= 0, ids.join(',')); + store.setDir(null); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/tests/logo-lib-ui.test.js b/tests/logo-lib-ui.test.js new file mode 100644 index 0000000..90944e0 --- /dev/null +++ b/tests/logo-lib-ui.test.js @@ -0,0 +1,109 @@ +'use strict'; +/* Kitaplık sil düğmesi gerçek bir currentColor çarpı çizer ve seçili + öğeyi kaldırınca kaydı temizler. Eski düğme icon özniteliğini SVG'ye + çevirmiyordu: koyu boş daire, tıklanınca da seçim silinmiyordu. */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +function element(tag) { + const node = { + tag, + children: [], + attrs: {}, + style: {}, + className: '', + text: '', + listeners: {}, + setAttribute(k, v) { this.attrs[k] = String(v); }, + getAttribute(k) { return this.attrs[k]; }, + appendChild(c) { this.children.push(c); return c; }, + insertBefore(c) { this.children.unshift(c); return c; }, + addEventListener(type, fn) { this.listeners[type] = fn; }, + querySelectorAll() { return []; }, + closest() { return null; }, + }; + Object.defineProperty(node, 'textContent', { + get() { return node.text; }, + set(v) { node.text = v == null ? '' : String(v); }, + }); + Object.defineProperty(node, 'innerHTML', { + get() { return ''; }, + set(v) { if (v === '') node.children = []; }, + }); + Object.defineProperty(node, 'childNodes', { get() { return node.children; } }); + node.classList = { + toggle(name, on) { + const parts = node.className.split(/\s+/).filter(Boolean); + const has = parts.includes(name); + const want = on === undefined ? !has : !!on; + node.className = (want ? parts.concat(has ? [] : [name]) : parts.filter((p) => p !== name)).join(' '); + }, + }; + return node; +} + +function walk(node, out) { + out.push(node); + for (const c of node.children || []) walk(c, out); + return out; +} + +test('sil düğmesi çarpı çizer, dosyayı siler ve seçimi bırakır', async () => { + global.window = global; + global.setInterval = () => 0; + global.clearInterval = () => {}; + global.document = { + createElement: element, + createElementNS: (ns, tag) => { const n = element(tag); n.attrs.xmlns = ns; return n; }, + body: { appendChild() {} }, + }; + delete require.cache[require.resolve('../src/shared/icons.js')]; + delete require.cache[require.resolve('../src/admin/logo-lib-ui.js')]; + require('../src/shared/icons.js'); + require('../src/admin/logo-lib-ui.js'); + + const removed = []; + let cleared = null; + window.api = { + logoLibList: async () => [{ id: 'a', name: 'Kapak', kind: 'image' }, { id: 'b', name: 'Diğer', kind: 'gif' }], + logoLibRemove: async (id) => { removed.push(id); return { ok: true }; }, + }; + const wrap = window.SVLogoLibUi.mount({ + selectedId: 'a', + onRemove: (it) => { cleared = it.id; }, + }); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + const nodes = walk(wrap, []); + const dels = nodes.filter((n) => n.tag === 'button' && String(n.className).indexOf('logo-lib-del') >= 0); + assert.strictEqual(dels.length, 2); + const icon = dels[0].children[0]; + assert.ok(icon, 'sil düğmesinde ikon'); + assert.match(icon.attrs.class, /svi-x/); + assert.strictEqual(icon.attrs.stroke, 'currentColor'); + assert.strictEqual(dels[0].attrs['data-icon'], 'x'); + const add = nodes.find((n) => n.tag === 'button' && /Kitaplığa Ekle/.test(n.textContent || '')); + assert.match(add.children[0].attrs.class, /svi-import/); + + await dels[0].listeners.click({ preventDefault() {}, stopPropagation() {} }); + assert.deepStrictEqual(removed, ['a']); + assert.strictEqual(cleared, 'a'); + + cleared = null; + await dels[1].listeners.click({ preventDefault() {}, stopPropagation() {} }); + assert.deepStrictEqual(removed, ['a', 'b']); + assert.strictEqual(cleared, 'b', 'seçili olmayan kart da sahnedeki logoyu bırakır'); + + window.api.logoLibRemove = async () => ({ ok: false }); + cleared = null; + await dels[1].listeners.click({ preventDefault() {}, stopPropagation() {} }); + assert.strictEqual(cleared, null); + + const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'admin', 'admin.css'), 'utf8'); + assert.match(css, /\.logo-lib-del \{[\s\S]*z-index:\s*3/); + assert.match(css, /\.logo-lib-del \.svi \{[^}]*color:\s*#fff/); + assert.match(css, /\.logo-lib-del:hover,\s*\n\.logo-lib-del:focus-visible \{[^}]*background:\s*#e23b3b/); +}); diff --git a/tests/mcp-modes.test.js b/tests/mcp-modes.test.js new file mode 100644 index 0000000..824ba42 --- /dev/null +++ b/tests/mcp-modes.test.js @@ -0,0 +1,273 @@ +'use strict'; +/* MCP mode gates and setup prompt. Calls the handlers directly. + No model process and no Claude, Codex, Cursor, or Ollama client. */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const mcp = require('../src/shared/mcp'); +const mcpServer = require('../src/main/mcp-server'); + +function ctx(mode, locale, extra) { + const cfg = { + mcp: { enabled: true, mode: mode }, + scenes: [{ id: 's1', name: 'One', data: { background: { type: 'solid' } } }], + autovj: {}, + stream: {}, + }; + return Object.assign({ + locale: function () { return locale || 'en'; }, + getConfig: function () { return cfg; }, + setConfig: function (next) { + Object.keys(cfg).forEach(function (k) { delete cfg[k]; }); + Object.assign(cfg, next); + }, + timeline: function () { return { ok: true, playing: false, time: 0 }; }, + newStreamToken: function () { return 'tok'; }, + }, extra || {}); +} + +function call(name, mode, locale, args) { + return Promise.resolve(mcp.callTool(name, args || {}, ctx(mode, locale))); +} + +test('default card is off and collapsed on read', function () { + const model = mcp.cardModel({}); + assert.strictEqual(model.enabled, false); + assert.strictEqual(model.mode, 'read'); + assert.strictEqual(model.setupVisible, false); + assert.deepStrictEqual(model.modes, ['read', 'apply', 'write', 'full', 'everything']); +}); + +test('each mode allows its representative tool and denies it below', async function () { + const cases = [ + ['read', 'sv_get_state', {}, null], + ['apply', 'sv_apply_scene', { id: 's1' }, 'read'], + ['write', 'sv_set_autovj', { enabled: true }, 'apply'], + ['full', 'sv_timeline_transport', { action: 'stop' }, 'write'], + ['everything', 'sv_rotate_stream_token', {}, 'full'], + ]; + for (const row of cases) { + const allowed = await call(row[1], row[0], 'en', row[2]); + assert.strictEqual(allowed.ok, true, row[0] + ' should allow ' + row[1] + ' ' + JSON.stringify(allowed)); + if (row[3]) { + const denied = await call(row[1], row[3], 'en', row[2]); + assert.strictEqual(denied.ok, false, row[3] + ' should deny ' + row[1]); + } + } +}); + +test('blocked calls name the minimum mode in Turkish and English', async function () { + const pairs = [ + ['sv_apply_scene', { id: 's1' }, 'read', 'uygula', 'apply'], + ['sv_set_autovj', { enabled: true }, 'apply', 'yaz', 'write'], + ['sv_timeline_transport', { action: 'stop' }, 'write', 'tam', 'full'], + ['sv_rotate_stream_token', {}, 'full', 'her şey', 'everything'], + ]; + for (const row of pairs) { + const tr = await call(row[0], row[2], 'tr', row[1]); + const en = await call(row[0], row[2], 'en', row[1]); + assert.match(tr.error, new RegExp('«' + row[3] + '»')); + assert.match(tr.error, /Modu sen değiştirme/); + assert.match(tr.error, /Yönetici panelini tıklama/); + assert.match(tr.error, /Kullanıcıya söyle/); + assert.match(en.error, new RegExp('«' + row[4] + '»')); + assert.match(en.error, /Do not change the mode/); + assert.match(en.error, /Do not click the admin UI/); + assert.match(en.error, /Tell the user and stop/); + assert.doesNotMatch(tr.error, /modunu «/); + assert.doesNotMatch(en.error, /Switch MCP mode/); + } +}); + +test('tools/list descriptions follow ui-language', async function () { + const tr = await mcp.handleRpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, ctx('read', 'tr')); + const en = await mcp.handleRpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, ctx('read', 'en')); + function desc(list, name) { + const tool = list.result.tools.find(function (t) { return t.name === name; }); + return tool.description; + } + const a = desc(tr, 'sv_get_state'); + const b = desc(en, 'sv_get_state'); + assert.notStrictEqual(a, b); + assert.match(a, /okur/); + assert.match(b, /Read show state/); + assert.match(desc(tr, 'sv_set_autovj'), /Otomatik VJ/); + assert.match(desc(en, 'sv_set_autovj'), /Auto VJ/); + assert.match(desc(tr, 'mcp_permissions'), /Modu sen değiştirme/); + assert.match(desc(en, 'mcp_permissions'), /Do not change the mode/); + assert.doesNotMatch(desc(en, 'mcp_permissions'), /Switch MCP mode/); +}); + +test('mcp_permissions reports the current mode and the mode a tool needs', async function () { + const report = await call('mcp_permissions', 'read', 'tr', { tool: 'sv_timeline_transport' }); + assert.strictEqual(report.ok, true); + assert.strictEqual(report.enabled, true); + assert.strictEqual(report.mode, 'read'); + assert.strictEqual(report.modeLabel, 'Okuma'); + assert.strictEqual(report.tool.name, 'sv_timeline_transport'); + assert.strictEqual(report.tool.minimumMode, 'full'); + assert.strictEqual(report.tool.minimumModeLabel, 'tam'); + assert.strictEqual(report.tool.allowed, false); + assert.match(report.note, /Modu sen değiştirme/); + assert.match(report.note, /tıklama/); + assert.ok(report.modes.length === 5); + const enPerm = await call('mcp_permissions', 'read', 'en', { tool: 'sv_timeline_transport' }); + assert.match(enPerm.note, /Do not change the mode/); + assert.match(enPerm.note, /Do not click the admin UI/); + assert.doesNotMatch(enPerm.note, /Switch MCP mode/); + const same = await mcp.callTool('mcp_permissions', { tool: 'sv_set_autovj' }, ctx('write', 'en')); + assert.strictEqual(same.mode, 'write'); + assert.strictEqual(same.tool.minimumMode, 'write'); + assert.strictEqual(same.tool.allowed, true); +}); + + +test('setup prompt is one text, names the configured port, and skips clients', function () { + const script = 'D:/SoundVisualizer/src/main/mcp-stdio.js'; + const command = mcp.commandBundle(script, 38471).shell; + const tr = mcp.installPrompt(script, 'tr', 38471); + const en = mcp.installPrompt(script, 'en', 38471); + const custom = mcp.installPrompt(script, 'en', 39001); + assert.notStrictEqual(tr, en); + assert.ok(tr.includes(command)); + assert.ok(en.includes(command)); + assert.ok(tr.includes('38471')); + assert.ok(en.includes('http://127.0.0.1:38471/mcp')); + assert.ok(custom.includes('http://127.0.0.1:39001/mcp')); + assert.match(tr, /SoundVisualizer/); + assert.match(en, /SoundVisualizer/); + assert.match(tr, /tools\/list/); + assert.match(en, /tools\/list/); + assert.match(en, /this computer only/); + assert.match(tr, /yalnız bu bilgisayar/); + assert.doesNotMatch(en, /loopback/i); + assert.doesNotMatch(tr, /geri döngü/); + assert.doesNotMatch(tr, /Claude|Codex|Cursor|Grok|APPDATA|USERPROFILE/); + assert.doesNotMatch(en, /Claude|Codex|Cursor|Grok|APPDATA|USERPROFILE/); + assert.doesNotMatch(tr, /ollama/i); + assert.doesNotMatch(en, /ollama/i); + assert.doesNotMatch(tr, /İzin kipini/); + assert.doesNotMatch(en, /permission mode/i); + const ids = mcp.clients(script, 38471).map(function (c) { return c.id; }); + assert.deepStrictEqual(ids, ['claude', 'codex', 'cursor', 'grok', 'grok-bot', 'ollama']); +}); + +test('MCP kurulumu Linux ve macOS yollarını verir; izin kipleri her platformda aynıdır', function () { + const script = 'mcp-stdio.js'; + assert.deepStrictEqual(mcp.MODES, ['read', 'apply', 'write', 'full', 'everything']); + const linux = mcp.clients(script, 38471, 'linux'); + const mac = mcp.clients(script, 38471, 'darwin'); + const win = mcp.clients(script, 38471, 'win32'); + function step(list, id) { return list.find(function (c) { return c.id === id; }).steps[1]; } + assert.match(step(linux, 'claude'), /~\/\.config\/Claude\/claude_desktop_config\.json/); + assert.match(step(mac, 'claude'), /~\/Library\/Application Support\/Claude\/claude_desktop_config\.json/); + assert.match(step(win, 'claude'), /%APPDATA%\\Claude\\claude_desktop_config\.json/); + assert.match(step(linux, 'codex'), /~\/\.codex\/config\.toml/); + assert.match(step(mac, 'cursor'), /~\/\.cursor\/mcp\.json/); + assert.match(step(win, 'cursor'), /%USERPROFILE%\\.cursor\\mcp\.json/); + assert.doesNotMatch(step(linux, 'claude'), /APPDATA|USERPROFILE/); + assert.strictEqual(linux[0].platform, 'linux'); + assert.strictEqual(mcp.commandBundle(script, 38471).command, 'node'); + const server = fs.readFileSync(path.join(__dirname, '..', 'src', 'main', 'mcp-server.js'), 'utf8'); + assert.match(server, /process\.platform !== 'win32'/); + assert.match(server, /0o600/); + assert.doesNotMatch(fs.readFileSync(path.join(__dirname, '..', 'src', 'main', 'mcp-stdio.js'), 'utf8'), /win32/); +}); + +test('read mode returns full state and still cannot write', async function () { + const state = await call('sv_get_state', 'read', 'en'); + assert.strictEqual(state.ok, true); + assert.ok(state.activePreset); + assert.ok(Array.isArray(state.effectsShowing)); + assert.ok(Array.isArray(state.scenes)); + assert.ok(Array.isArray(state.layersOn)); + assert.ok(state.audio && 'bpm' in state.audio); + assert.ok('nowPlaying' in state); + assert.ok('analysis' in state); + assert.ok(state.stack); + assert.ok('stream' in state); + assert.ok('textureShare' in state); + assert.ok('displays' in state); + const denied = await call('sv_apply_scene', 'read', 'en', { id: 's1' }); + assert.strictEqual(denied.ok, false); +}); + +test('floating window open and close call the existing handler and stop below full', async function () { + const denied = await mcp.callTool('sv_set_floating_open', { open: true }, ctx('read', 'tr')); + assert.strictEqual(denied.ok, false); + assert.match(denied.error, /«tam»/); + assert.match(denied.error, /Modu sen değiştirme/); + assert.match(denied.error, /Kullanıcıya söyle/); + let seen = null; + const opened = await mcp.callTool('sv_set_floating_open', { open: true }, ctx('full', 'en', { + setFloatingOpen: function (open) { seen = open; return { open: open }; }, + })); + assert.strictEqual(opened.ok, true); + assert.strictEqual(opened.open, true); + assert.strictEqual(seen, true); + const closed = await mcp.callTool('sv_set_floating_open', { open: false }, ctx('full', 'en', { + setFloatingOpen: function (open) { seen = open; return { open: open }; }, + })); + assert.strictEqual(closed.ok, true); + assert.strictEqual(closed.open, false); + assert.strictEqual(seen, false); + const trList = await mcp.handleRpc({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, ctx('read', 'tr')); + const enList = await mcp.handleRpc({ jsonrpc: '2.0', id: 4, method: 'tools/list' }, ctx('read', 'en')); + function desc(list) { + return list.result.tools.find(function (t) { return t.name === 'sv_set_floating_open'; }).description; + } + assert.match(desc(trList), /Yüzen pencereyi/); + assert.match(desc(enList), /floating PiP window/); +}); + +test('stop all clips calls the deck stop and is denied in read mode', async function () { + const denied = await mcp.callTool('sv_stop_clips', {}, ctx('read', 'en')); + assert.strictEqual(denied.ok, false); + assert.match(denied.error, /Do not change the mode/); + assert.match(denied.error, /«apply»/); + let called = 0; + const stopped = await mcp.callTool('sv_stop_clips', {}, ctx('apply', 'tr', { + stopClips: function () { called += 1; return { ok: true }; }, + })); + assert.strictEqual(stopped.ok, true); + assert.strictEqual(called, 1); +}); + +test('HTTP stays on 38471 and does not hop when that port is busy', async function () { + const net = require('net'); + const holder = net.createServer(); + let held = false; + await new Promise(function (resolve, reject) { + let settled = false; + holder.once('error', function (err) { + if (settled) return; + settled = true; + if (err && err.code === 'EADDRINUSE') resolve(); + else reject(err); + }); + holder.listen(38471, '127.0.0.1', function () { + if (settled) return; + settled = true; + held = true; + resolve(); + }); + }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sv-mcp-busy-')); + const cfg = { mcp: { enabled: true, mode: 'read', port: 38471 } }; + const server = mcpServer.create({ userData: dir, getConfig: function () { return cfg; } }); + try { + const status = await server.start(); + assert.strictEqual(status.host, '127.0.0.1'); + assert.strictEqual(status.configuredPort, 38471); + assert.strictEqual(status.requestedPort, 38471); + assert.strictEqual(status.portBusy, true); + assert.strictEqual(status.running, false); + assert.strictEqual(status.port, 0); + } finally { + await server.stop(); + if (held) await new Promise(function (resolve) { holder.close(function () { resolve(); }); }); + } +}); diff --git a/tests/milkdrop-reduce-motion.test.js b/tests/milkdrop-reduce-motion.test.js index fce09a2..e3f6683 100644 --- a/tests/milkdrop-reduce-motion.test.js +++ b/tests/milkdrop-reduce-motion.test.js @@ -268,12 +268,12 @@ test('ölçü kipinde vuruşa yuvarlanan süre de uzun', () => { }); test('elle "şimdi kes" yine kesiyor: açık bir komut', () => { - const { m } = engine(null); + const { m, win } = engine(null); m._ensurePreset(cfgFor(PRESETS[0])); m._reduced = true; m._ensurePreset(cfgFor(PRESETS[1], {}, { cutTo: 'p2' })); assert.ok(!m.oldPreset, 'karışmadan yüklenmeli'); - assert.strictEqual(m.presetKey, 'p2|' + PRESETS[1].source.length); + assert.strictEqual(m.presetKey, 'p2|' + PRESETS[1].source.length + '#' + win.SVMilkdrop.sourceTag(PRESETS[1].source)); }); test('izleyen: liderin sert geçişi hareketi azaltan ekranda karışarak', () => { diff --git a/tests/nowplaying-open.test.js b/tests/nowplaying-open.test.js new file mode 100644 index 0000000..003746d --- /dev/null +++ b/tests/nowplaying-open.test.js @@ -0,0 +1,173 @@ +'use strict'; +/* Görselleştirici, müzik zaten çalarken açılınca oynatıcı boş kalıyordu. + * + * Ana süreç pencere yüklenir yüklenmez 'now-playing' yolluyor. Sayfa ise + * dinleyiciyi yapılandırmayı istedikten sonra kuruyor. Electron aradaki + * mesajı tutmuyor. Kaynak aynı parçayı tekrar bildirmiyor; duraklatıp + * devam etmek yeni bir fark olduğu için oynatıcı ancak o zaman çıkıyordu. */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { createLateEvent, catchUp } = require('../src/shared/late-event.js'); + +const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + +test('çalan parça dinleyiciden önce gelirse abone olunca verilir', () => { + const slot = createLateEvent(); + const playing = { has: true, playing: true, title: 'Nibbana' }; + slot.push(playing); + let got = null; + slot.subscribe((st) => { got = st; }); + assert.strictEqual(got, playing); +}); + +test('abone olduktan sonra duraklatma da iletilir', () => { + const slot = createLateEvent(); + const got = []; + slot.subscribe((st) => got.push(st.playing)); + slot.push({ has: true, playing: true }); + slot.push({ has: true, playing: false }); + assert.deepStrictEqual(got, [true, false]); +}); + +test('geç abone ilk değil son örneği alır', () => { + const slot = createLateEvent(); + slot.push({ has: true, title: 'A' }); + slot.push({ has: true, title: 'B' }); + let title = ''; + slot.subscribe((st) => { title = st.title; }); + assert.strictEqual(title, 'B'); +}); + +test('açılışta güncel parça istenir ama arada gelen canlı örnek ezilmez', async () => { + const live = { state: null }; + let listener = null; + const api = { + onNowPlaying(cb) { listener = cb; }, + nowPlayingCurrent() { + listener({ has: true, playing: false, title: 'canlı' }); + return Promise.resolve({ has: true, playing: true, title: 'eski' }); + }, + }; + await catchUp(live, api); + assert.strictEqual(live.state.title, 'canlı'); + assert.strictEqual(live.state.playing, false); +}); + +test('hiç iletilmeyen çalan parça açılışta alınır', async () => { + const live = { state: null }; + const api = { + onNowPlaying() {}, + nowPlayingCurrent() { + return Promise.resolve({ has: true, playing: true, title: 'Nibbana' }); + }, + }; + await catchUp(live, api); + assert.strictEqual(live.state.title, 'Nibbana'); + assert.strictEqual(live.state.playing, true); +}); + +test('parça yokken boş durum oynatıcıyı açmaz', async () => { + const live = { state: null }; + const api = { + onNowPlaying() {}, + nowPlayingCurrent() { + return Promise.resolve({ has: false, playing: false, title: '' }); + }, + }; + await catchUp(live, api); + assert.strictEqual(live.state, null); +}); + +test('preload çalan parçayı sayfa abone olmadan dinler', () => { + const pre = read('src/main/preload-visualizer.js'); + const onAt = pre.indexOf("ipcRenderer.on('now-playing'"); + const exposeAt = pre.indexOf('exposeInMainWorld'); + assert.ok(onAt > 0 && exposeAt > onAt, 'dinleyici sayfa abone olmadan kurulmalı'); + assert.match(pre, /onNowPlaying: \(cb\) => nowPlayingEvents\.subscribe\(cb\)/); + assert.match(pre, /nowPlayingCurrent: \(\) => ipcRenderer\.invoke\('nowplaying:current'\)/); + assert.equal((pre.match(/ipcRenderer\.on\('now-playing'/g) || []).length, 1); + assert.doesNotMatch(pre, /require\(\s*['"]\.\./, 'preload kum havuzunda yerel require çöker ve api hiç kurulmaz'); + assert.equal((pre.match(/require\(/g) || []).length, 1, 'yalnızca electron require edilebilir'); +}); + +/* Electron preload kum havuzu yerel dosyayı require edemez. O satır + betiği düşürünce window.api kurulmaz ve görselleştirici + `api.onPresetsDelta` okurken "Başlatılamadı" der. */ +function loadPreload() { + const exposed = {}; + const listeners = {}; + const Module = require('module'); + const orig = Module.prototype.require; + const preload = require.resolve('../src/main/preload-visualizer.js'); + Module.prototype.require = function (id) { + if (id === 'electron') { + return { + contextBridge: { + exposeInMainWorld(name, value) { exposed[name] = value; }, + }, + ipcRenderer: { + on(channel, cb) { + (listeners[channel] = listeners[channel] || []).push(cb); + }, + invoke() { return Promise.resolve(null); }, + send() {}, + }, + }; + } + if (typeof id === 'string' && (id.startsWith('.') || id.includes('late-event'))) { + throw new Error('module not found: ' + id); + } + return orig.apply(this, arguments); + }; + try { + delete require.cache[preload]; + require(preload); + } finally { + Module.prototype.require = orig; + delete require.cache[preload]; + } + return { exposed, listeners }; +} + +test('kum havuzu preload api kurar; geç abone çalan parçayı alır', () => { + const { exposed, listeners } = loadPreload(); + assert.equal(typeof exposed.api.onPresetsDelta, 'function'); + assert.equal(listeners['now-playing'].length, 1); + const playing = { has: true, playing: true, title: 'Nibbana' }; + listeners['now-playing'][0]({}, playing); + let got = null; + exposed.api.onNowPlaying((st) => { got = st; }); + assert.strictEqual(got, playing); + got = null; + listeners['now-playing'][0]({}, { has: true, playing: false, title: 'Nibbana' }); + assert.strictEqual(got.playing, false); +}); + +test('köprü yokken görselleştirici onPresetsDelta okumaz', () => { + const vis = read('src/visualizer/visualizer.js'); + const guard = vis.indexOf('if (!window.api)'); + const delta = vis.indexOf('onPresetsDelta'); + assert.ok(guard > 0 && guard < delta, 'köprü denetimi onPresetsDelta’dan önce'); + assert.match(vis, /pencere köprüsü yok/); + assert.match(vis, /if \(window\.api && window\.api\.onPresetsDelta\)/); +}); + +test('görselleştirici açılışta güncel parçayı ister', () => { + const vis = read('src/visualizer/visualizer.js'); + assert.match(vis, /SVLateEvent\.catchUp\(window\.SVNowLive, window\.api\)/); + for (const rel of ['src/visualizer/index.html', 'src/web/overlay.html']) { + const html = read(rel); + const late = html.indexOf('late-event.js'); + const boot = html.lastIndexOf('visualizer.js'); + assert.ok(late > 0 && boot > late, rel + ': yardımcı görselleştiriciden önce yüklenmeli'); + } +}); + +test('açılan pencere eldeki çalan parçayı yine yollar', () => { + const main = read('src/main/main.js'); + const sends = main.match(/mediaSession\.current\(\)\.has\)[^\n]*send\('now-playing'/g) || []; + assert.ok(sends.length >= 2, 'ekran ve yüzen pencere açılışta durumu yollamalı'); +}); diff --git a/tests/presets-scale.test.js b/tests/presets-scale.test.js index fe7758c..3fd9ef3 100644 --- a/tests/presets-scale.test.js +++ b/tests/presets-scale.test.js @@ -91,6 +91,39 @@ test('depo: dosyalar bir kez okunuyor; kayıt ve silme önbelleği güncelliyor' assert.strictEqual(STORE.get('yok'), null); }); +test('depo: syncDisk yeni dosyayı, değişen içeriği ve silmeyi verir; kendi kaydı boş döner', () => { + const d = tmpStore(); + assert.deepStrictEqual(STORE.syncDisk(), { upsert: [], remove: [] }); + write(d, md('md_a', 2)); + assert.deepStrictEqual(STORE.syncDisk().upsert.map((p) => p.id), ['md_a']); + const changed = md('md_a', 4, { source: '[preset00]\nzoom=2\n' }); + const file = path.join(d, 'md_a.json'); + fs.writeFileSync(file, JSON.stringify(changed)); + const again = STORE.syncDisk(['md_a.json']); + assert.strictEqual(again.upsert.length, 1); + assert.strictEqual(again.upsert[0].source, changed.source); + const saved = STORE.save(md('md_b', 1)); + assert.ok(saved.ok); + assert.deepStrictEqual(STORE.syncDisk([saved.preset.id + '.json']), { upsert: [], remove: [] }); + fs.unlinkSync(file); + assert.deepStrictEqual(STORE.syncDisk().remove, ['md_a']); + assert.ok(!STORE.list().some((p) => p.id === 'md_a')); +}); + +test('kaynak etiketi: aynı uzunluk farklı metin', () => { + const ctx = { console, performance }; + ctx.window = ctx; + vm.createContext(ctx); + vm.runInContext(read('src/shared/milkdrop.js'), ctx, { filename: 'milkdrop.js' }); + const tag = ctx.SVMilkdrop.sourceTag; + const a = 'zoom=1'; + const b = 'zoom=2'; + assert.notStrictEqual(tag(a), tag(b)); + assert.strictEqual(tag(a), tag(a)); + assert.ok(String(tag(a)).endsWith(':' + a.length)); + assert.match(read('src/visualizer/modes/milkdrop.js'), /sourceTag/); +}); + test('depo: klasöre elle eklenen ya da silinen dosya yine görünüyor', () => { const d = tmpStore(); write(d, md('md_a', 2)); @@ -203,8 +236,11 @@ test('ana süreç: kayıt ve silme yalnız değişeni yayınlıyor; bütün list }); test('ana süreç: depo açılışta arka planda okunuyor; liste isteği onu bekliyor', () => { - assert.match(MAIN, /ipcMain\.handle\('presets:list', async \(\) => \{\s*await presetsStore\.warm\(\);\s*return presetsStore\.list\(\);/); - assert.match(MAIN, /syncNowPlaying\(\);\s*presetsStore\.warm\(\)\.catch\(\(\) => \{\}\);/); + assert.match(MAIN, /ipcMain\.handle\('presets:list', async \(\) => \{\s*await presetsStore\.warm\(\);\s*const list = presetsStore\.list\(\);\s*const disk = presetsStore\.syncDisk\(\);\s*if \(disk\.upsert\.length \|\| disk\.remove\.length\) broadcastPresetDelta\(disk\.upsert, disk\.remove\);\s*return list;/); + assert.match(MAIN, /syncNowPlaying\(\);\s*presetsStore\.warm\(\)\.then\(\(\) => \{\s*presetsStore\.watch\(/); + assert.match(MAIN, /ipcMain\.handle\('presets:since'/); + assert.match(MAIN, /presetJournal/); + assert.match(MAIN, /function followActivePreset/); assert.match(MAIN, /mdPresetSource: \(id\) => \{\s*const p = presetsStore\.get\(id\);\s*return p && p\.kind === 'milkdrop' && typeof p\.source === 'string' \? p\.source : null;/); }); @@ -213,14 +249,19 @@ test('köprüler: pencere ve panel değişikliği ve ilerlemeyi dinliyor', () => const A = read('src/main/preload-admin.js'); assert.match(A, /onPresetsDelta: \(cb\) => ipcRenderer\.on\('presets-delta', \(e, d\) => cb\(d\)\),/); assert.match(A, /onPresetsProgress: \(cb\) => ipcRenderer\.on\('presets-progress', \(e, p\) => cb\(p\)\),/); - assert.match(bare(read('src/admin/admin.js')), /window\.api\.onPresetsDelta\(\(d\) => \{\s*window\.SVPresets\.applyDelta\(d\);\s*render\(\);/); + const adminSrc = bare(read('src/admin/admin.js')); + assert.match(adminSrc, /onPresetsDelta\(onPresetDelta\)/); + assert.match(adminSrc, /presetsReady = true/); + assert.match(adminSrc, /const onPresetDelta = \(d\) => \{[\s\S]*?applyDelta\(d\);\s*render\(\);/); + assert.match(adminSrc, /presetsSince/); + assert.match(read('src/visualizer/visualizer.js'), /presetsSince/); }); // ------------------------------------------------------------ görselleştirici test('görselleştirici: sahne yalnız bir Studio preseti değişince yeniden kuruluyor', () => { const V = read('src/visualizer/visualizer.js'); - const m = /window\.api\.onPresetsDelta\((\(d\) => \{[\s\S]*?\n {6}\})\);/.exec(V); + const m = /const onPresetDelta = (\(d\) => \{[\s\S]*?\n \};)/.exec(V); assert.ok(m, 'değişiklik dinleyicisi bulunamadı'); const run = (list, delta) => { const calls = []; @@ -447,7 +488,7 @@ test('motor: kaynağı yolda olan seçim gelene kadar ekrandaki preset sürüyor resolve('[preset00]\nzoom=0.99\n'); await new Promise((r) => setImmediate(r)); e.m._ensurePreset(cfg); - assert.strictEqual(e.m.presetKey, 'm2|' + '[preset00]\nzoom=0.99\n'.length); + assert.strictEqual(e.m.presetKey, 'm2|' + '[preset00]\nzoom=0.99\n'.length + '#' + e.win.SVMilkdrop.sourceTag('[preset00]\nzoom=0.99\n')); assert.strictEqual(e.win.SVPresets.get('m2').source, '[preset00]\nzoom=0.99\n', 'kaynak listeye yazıldı'); assert.strictEqual(e.m._lazySource('m2'), '[preset00]\nzoom=0.99\n'); assert.strictEqual(asked, 1, 'bir daha istenmiyor'); diff --git a/tests/round-image-glow.test.js b/tests/round-image-glow.test.js index 3449180..28b798d 100644 --- a/tests/round-image-glow.test.js +++ b/tests/round-image-glow.test.js @@ -5,7 +5,8 @@ const fs = require('fs'); const path = require('path'); test('SVRoundImage draws rounded silhouette before glow', () => { - const ri = fs.readFileSync(path.join(__dirname, '..', 'src', 'visualizer', 'modes', 'round-image.js'), 'utf8'); + const riPath = path.join(__dirname, '..', 'src', 'visualizer', 'modes', 'round-image.js'); + const ri = fs.readFileSync(riPath, 'utf8'); assert.match(ri, /function drawImage/); assert.match(ri, /shadowBlur/); // Radius path: clip on scratch (s.clip), then draw scratch to ctx with shadowBlur @@ -14,6 +15,54 @@ test('SVRoundImage draws rounded silhouette before glow', () => { const afterClip = ri.slice(clipAt); const glowAt = afterClip.indexOf('ctx.shadowBlur = glow'); assert.ok(glowAt > 0, 'destination glow after scratch clip'); + + // ~32% logo glow is an edge halo: blur the silhouette, punch out the + // opaque core, add the fringe, then paint the sharp sprite on top. + const api = require(riPath); + const plan = api.edgeBloomLayout(0.32 * 40); + const full = api.edgeBloomLayout(40); + assert.equal(plan.knockout, 'destination-out'); + assert.equal(plan.composite, 'lighter'); + assert.equal(full.composite, 'lighter'); + assert.ok(plan.glow > 12.8, 'low glow still blooms, wider than the old shadow radius'); + assert.equal(full.glow, 120); + assert.equal(full.strength, 3); + const early = api.edgeBloomLayout(4); + assert.ok(early.glow >= 20 && early.strength >= 0.5, 'visible before 20%'); + const mid = api.edgeBloomLayout(16); + assert.ok(mid.glow >= 70 && mid.glow < full.glow, '40% scatters like the high end, not a tight blob'); + const hi = api.edgeBloomLayout(32, 0.4); + assert.ok(Math.abs(hi.glow - mid.glow * 2) < 0.02); + assert.equal(hi.strength, mid.strength); + assert.equal(api.edgeBloomLayout(0).glow, 0); + assert.equal(api.edgeBloomLayout(0).strength, 0); + let prevStep = api.edgeBloomLayout(0); + for (let step = 1; step <= 40; step++) { + const cur = api.edgeBloomLayout(step); + const dg = cur.glow - prevStep.glow; + const ds = cur.strength - prevStep.strength; + assert.ok(dg > 0 && dg < 6, 'radius step ' + step); + assert.ok(ds > 0 && ds < 0.2, 'strength step ' + step); + prevStep = cur; + } + assert.ok(full.glow > plan.glow && full.strength > plan.strength, '100% stays stronger'); + assert.ok(plan.pad >= plan.glow * 2, 'padding keeps the blur from clipping into a flat veil'); + assert.ok(full.pad >= full.glow * 2); + const bloomStart = ri.indexOf('function drawEdgeBloom'); + const bloomEnd = ri.indexOf('function drawImage'); + assert.ok(bloomStart > 0 && bloomEnd > bloomStart); + const bloom = ri.slice(bloomStart, bloomEnd); + assert.equal(/shadowColor\s*=/.test(bloom), false); + const blurAt = bloom.indexOf("b.filter = 'blur("); + const knockAt = bloom.indexOf('layout.knockout'); + const lightAt = bloom.indexOf('layout.composite'); + assert.ok(bloom.includes('layout.strength'), 'halo intensity follows the slider'); + assert.equal(bloom.includes('layout.passes'), false); + assert.equal(/Math\.round/.test(ri.slice(ri.indexOf('function edgeBloomLayout'), ri.indexOf('function drawFitted'))), false); + const haloAt = bloom.indexOf('drawImage(bc.canvas'); + const sharpAt = bloom.lastIndexOf('drawImage(sc.canvas'); + assert.ok(blurAt > 0 && blurAt < knockAt && knockAt < lightAt && lightAt < haloAt && haloAt < sharpAt, + 'blur, knock out core, add halo, then sharp sprite'); }); test('logo sprites nowplaying use SVRoundImage', () => { @@ -22,6 +71,25 @@ test('logo sprites nowplaying use SVRoundImage', () => { const src = fs.readFileSync(path.join(root, rel), 'utf8'); assert.match(src, /SVRoundImage/, rel); } + const layers = fs.readFileSync(path.join(root, 'layers.js'), 'utf8'); + const paintStart = layers.indexOf('_paintLogo'); + const paintEnd = layers.indexOf('_drawLogoToCanvas'); + const paint = layers.slice(paintStart, paintEnd); + const callStart = paint.indexOf('SVRoundImage.drawImage'); + const callEnd = paint.indexOf('} else'); + const call = paint.slice(callStart, callEnd); + assert.match(call, /edgeBloom:\s*true/); + assert.equal(call.includes('255,255,255'), false, 'logo glow must not be a white shadow'); + const np = fs.readFileSync(path.join(root, 'modes', 'nowplaying.js'), 'utf8'); + const coverAt = np.indexOf('const glowAmt'); + const coverEnd = np.indexOf('ctx.restore()', coverAt); + const cover = np.slice(coverAt, coverEnd); + assert.match(cover, /visualizer\.glow/); + assert.match(cover, /glowAmt \* 40 \* \(minDim \/ 1080\)/); + assert.match(cover, /edgeBloom:\s*true/); + assert.match(cover, /glowAmount:\s*glowAmt/); + assert.equal(cover.includes('shadowColor'), false, 'cover glow is not a shadow veil'); + assert.match(layers, /visSettings\.glow/); const adminHtml = fs.readFileSync(path.join(__dirname, '..', 'src', 'admin', 'index.html'), 'utf8'); assert.match(adminHtml, /round-image\.js/); });