diff --git a/README.md b/README.md index 35a2f9b..f1b3a57 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ layers and **40 GPU effects**, an **OBS overlay**, **Spout / Syphon**, **project [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-e11d2a.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#download) -[![Tests](https://img.shields.io/badge/tests-3154%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3165%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -1721,7 +1721,7 @@ npm test npm start -- --smoke ``` -**3154 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3165 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 staying diff --git a/README.tr.md b/README.tr.md index db174ff..be52c8a 100644 --- a/README.tr.md +++ b/README.tr.md @@ -18,7 +18,7 @@ destesi**, ve **MCP üzerinden yapay zekâ kontrolü**. [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) [![Lisans: MIT](https://img.shields.io/badge/Lisans-MIT-e11d2a.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#hızlı-başlangıç) -[![Test](https://img.shields.io/badge/test-3154%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3165%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -1724,7 +1724,7 @@ npm test npm start -- --smoke ``` -**3154 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3165 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ı kalması ve görüş diff --git a/ROADMAP.md b/ROADMAP.md index c12070b..d78a23b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,7 @@ npm test npm start -- --smoke ``` -- **3154 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3165 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 — 1597 at that tag — 1402 more with v3.1.5 — 2999 at that tag — and 27 diff --git a/src/admin/admin.js b/src/admin/admin.js index 209953c..500e0b3 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -264,7 +264,8 @@ host = el('div', { class: 'toast hidden', id: 'toast' }); document.body.appendChild(host); } - host.textContent = message; + // Messages from the main process and modules are Turkish source text + host.textContent = window.SVI18n && window.SVI18n.t ? window.SVI18n.t(String(message)) : message; host.className = 'toast' + (kind ? ' ' + kind : ''); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => host.classList.add('hidden'), 4200); @@ -1633,6 +1634,19 @@ tarafı da uygulama açılınca kendiliğinden bağlanıyor. */ for (const s of selectedApps) { if (shown.has(AA.baseName(s.match))) continue; + /* A plain key saved before Linux split apps by name ("pacat") + still captures every app of that binary: show it as running. */ + const live = AA.sameApp ? apps.filter((a) => AA.sameApp(a.match, AA.baseName(s.match))) : []; + if (live.length) { + appRows.push(makeRow( + { kind: 'app', match: s.match, label: s.label }, + live.some((a) => a.audible) ? 'music' : 'mute', + s.label || s.match, + live.length > 1 ? live.length + ' süreç' : '', + appMeter(s.match) + )); + continue; + } appRows.push(makeRow( { kind: 'app', match: s.match, label: s.label }, 'pause', s.label || s.match, 'çalışmıyor' @@ -1807,7 +1821,7 @@ }); const list = el('div', { class: 'export-queue', id: 'exportQueueList' }); const box = el('div', { class: 'ctrl' }, [ - el('label', { class: 'lbl', text: 'Kuyruk' }), + el('label', { class: 'lbl', text: 'Dışa Aktarma Kuyruğu' }), el('div', { class: 'studio-note dim-hint', text: 'Her iş eklendiği andaki sahneyi ve ayarları kullanır; biri hata verse de sonraki çalışır. Bir sahneyi birden çok şarkıya ya da bir şarkıyı birden çok çözünürlüğe aynı klasöre çıkarmak için.' }), el('div', { class: 'row', style: 'flex-wrap:wrap;gap:6px' }, [add, addMany, run, clear]), list, @@ -6182,6 +6196,11 @@ /* 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); + /* Timeline-follow recording turned on/off by MCP or another client + must take effect now, not only when the Output card renders. */ + if (window.SVRecordPanel && window.SVRecordPanel.ensureFollow) { + window.SVRecordPanel.ensureFollow(!!(cfg.recording && cfg.recording.followTimeline)); + } }); // Aynı ayar klasörünü kullanan başka kopyalar ve ayar çakışması (#564) diff --git a/src/main/main.js b/src/main/main.js index 7e0e081..0b88767 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -526,6 +526,33 @@ if (process.platform === 'win32') { let forceQuitApp = false; let isConfirmingClose = false; +/* SIGTERM/SIGINT (systemd, kill, Ctrl+C) must not wait on the confirm-close + prompt or a running export: on a headless session nobody answers it. + The force flag is set BEFORE any window closes so the admin window's + 'close' handler and 'before-quit' skip the prompt; the fallback exit + does not depend on dialogs or the quit sequence finishing. */ +let signalQuit = false; +function quitOnSignal() { + if (signalQuit) { process.exit(0); return; } // second signal: no more waiting + signalQuit = true; + forceQuitApp = true; + try { exportQueue.stopping = true; } catch { /* not ready yet */ } + try { if (exportState) exportState.cancel = true; } catch { /* not ready yet */ } + setTimeout(() => { try { app.exit(0); } finally { process.exit(0); } }, 5000); + try { app.quit(); } catch { app.exit(0); } +} +/* Chromium installs its own SIGTERM handler during startup, which closes + windows without telling us. Re-registering after it (on ready and after + the first window) puts Node's handler back in charge. */ +function installSignalHandlers() { + for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) { + process.removeListener(sig, quitOnSignal); + process.on(sig, quitOnSignal); + } +} +installSignalHandlers(); +app.whenReady().then(() => { installSignalHandlers(); setTimeout(installSignalHandlers, 1000); }); + ipcMain.on('confirm-close-approved', () => { forceQuitApp = true; if (adminWin && !adminWin.isDestroyed()) adminWin.close(); @@ -533,7 +560,7 @@ ipcMain.on('confirm-close-approved', () => { }); async function confirmCloseIfNeeded(parentWin) { - if (forceQuitApp || SMOKE) return true; + if (forceQuitApp || signalQuit || SMOKE) return true; const cfg = currentConfig || loadSettings(); const confirmClose = !!(cfg && cfg.power && cfg.power.confirmClose); if (!confirmClose || !anyVisualizerOpen()) return true; @@ -608,7 +635,7 @@ function createAdminWindow() { } adminWin.on('close', (e) => { - if (forceQuitApp || SMOKE) return; + if (forceQuitApp || signalQuit || SMOKE) return; const cfg = currentConfig || loadSettings(); if (cfg?.power?.confirmClose && anyVisualizerOpen()) { e.preventDefault(); @@ -4620,7 +4647,7 @@ async function startExportJob(opts, bench, extra) { /* Sahne yapılandırması: kuyruk işi eklendiği anın kopyasını taşıyor (#526); sonradan yapılan değişiklik sıradaki işleri değiştirmesin. */ const jobCfg = (extra && extra.cfg) || currentConfig; - if (exportState) return { ok: false, error: 'Zaten bir dışa aktarma sürüyor.' }; + if (exportState) return { ok: false, error: trUi('Zaten bir dışa aktarma sürüyor.', 'An export is already running.') }; opts = opts || {}; const audioPath = opts.audioPath; const outputPath = opts.outputPath; @@ -4628,13 +4655,13 @@ async function startExportJob(opts, bench, extra) { tcp:// ya da pipe: gibi adresleri de çıktı sayar: MCP'nin kuralı burada da geçerli, yerel, mutlak bir .mp4. */ if (typeof audioPath !== 'string' || !path.isAbsolute(audioPath) || !fs.existsSync(audioPath)) { - return { ok: false, error: 'Ses dosyası bulunamadı.' }; + return { ok: false, error: trUi('Ses dosyası bulunamadı.', 'Audio file not found.') }; } - if (!outputPath) return { ok: false, error: 'Çıktı yolu seçilmedi.' }; + if (!outputPath) return { ok: false, error: trUi('Çıktı yolu seçilmedi.', 'No output path was chosen.') }; const fmt = exportFormats.format(opts.format || exportFormats.DEFAULT_FORMAT); if (!fmt) return { ok: false, error: trUi('Bilinmeyen biçim: ', 'Unknown format: ') + opts.format }; const outBad = require('../shared/mcp').badOutPath(outputPath, [fmt.ext]); - if (outBad) return { ok: false, error: 'Çıktı yolu geçersiz: ' + outBad }; + if (outBad) return { ok: false, error: trUi('Çıktı yolu geçersiz: ', 'Invalid output path: ') + outBad }; const res = exportFormats.RESOLUTIONS[opts.resolution] || exportFormats.RESOLUTIONS['1080p']; const w = res[0]; @@ -4683,7 +4710,7 @@ async function startExportJob(opts, bench, extra) { try { audioBuf = fs.readFileSync(audioPath); } catch (err) { - return { ok: false, error: 'Ses dosyası okunamadı: ' + err.message }; + return { ok: false, error: trUi('Ses dosyası okunamadı: ', 'Could not read the audio file: ') + err.message }; } let track = { @@ -4782,7 +4809,7 @@ async function startExportJob(opts, bench, extra) { try { ffmpegProc = spawn(ff, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] }); } catch (err) { - return { ok: false, error: 'ffmpeg başlatılamadı: ' + err.message }; + return { ok: false, error: trUi('ffmpeg başlatılamadı: ', 'Could not start ffmpeg: ') + err.message }; } exportState = { @@ -4802,23 +4829,29 @@ async function startExportJob(opts, bench, extra) { phase: 'start', encoder, encoderLabel: exportFormats.ENCODERS[encoderId] ? exportFormats.ENCODERS[encoderId].label : '', }); - ffmpegProc.stderr.on('data', (d) => { - if (!exportState) return; + /* Every handler below is bound to THIS job. After a cancel the old + ffmpeg's late 'exit'/'close' used to finalize the NEXT export (it + checked the current exportState), destroying its render window. */ + const job = exportState; + const proc = ffmpegProc; + const isMine = () => exportState === job; + proc.stderr.on('data', (d) => { + if (!isMine()) return; exportState.ffErr += d.toString(); if (exportState.ffErr.length > 8000) exportState.ffErr = exportState.ffErr.slice(-8000); }); - ffmpegProc.stdin.on('error', () => {}); // iptal/kapanışta EPIPE'i yut - ffmpegProc.on('error', (err) => finalizeExport('error', 'ffmpeg hatası: ' + err.message)); + proc.stdin.on('error', () => {}); // iptal/kapanışta EPIPE'i yut + proc.on('error', (err) => { if (isMine()) finalizeExport('error', trUi('ffmpeg hatası: ', 'ffmpeg error: ') + err.message); }); const onFfClosed = (code) => { // Süre dolunca değil, süreç gerçekten kapanınca. null kod ikinci // olayın sayısal sonucunu bekler; erken 'tamam' arayüzü yanıltıyordu. - if (!exportState || code == null) return; + if (!isMine() || code == null) return; if (code === 0) finalizeExport('done'); else if (exportState.cancel) finalizeExport('cancelled'); - else finalizeExport('error', 'ffmpeg çıkış kodu ' + code + '\n' + (exportState.ffErr || '').slice(-1200)); + else finalizeExport('error', trUi('ffmpeg çıkış kodu ', 'ffmpeg exit code ') + code + '\n' + (exportState.ffErr || '').slice(-1200)); }; - ffmpegProc.on('exit', onFfClosed); - ffmpegProc.on('close', onFfClosed); + proc.on('exit', onFfClosed); + proc.on('close', onFfClosed); // Gizli render penceresi (offscreen kanvas; ekrana çizilmez) exportWin = new BrowserWindow({ @@ -4833,23 +4866,27 @@ async function startExportJob(opts, bench, extra) { backgroundThrottling: false, }, }); - attachSmoke(exportWin, 'EXPORT'); - exportWin.on('closed', () => { exportWin = null; }); - exportWin.webContents.on('render-process-gone', () => { - if (!exportState) return; + const win = exportWin; + attachSmoke(win, 'EXPORT'); + // Only clear the global if it still points at this job's window + win.on('closed', () => { if (exportWin === win) exportWin = null; }); + win.webContents.on('render-process-gone', () => { + if (!isMine()) return; if (exportState.finishAt) { endExportStdin(); return; } - finalizeExport('error', 'Görüntü süreci kapandı. Dışa aktarma durduruldu.', { keep: true }); + finalizeExport('error', trUi('Görüntü süreci kapandı. Dışa aktarma durduruldu.', 'The render process exited. Export stopped.'), { keep: true }); }); try { - await exportWin.loadFile(path.join(__dirname, '..', 'exporter', 'index.html')); + await win.loadFile(path.join(__dirname, '..', 'exporter', 'index.html')); } catch (err) { - finalizeExport('error', 'Render penceresi yüklenemedi: ' + err.message); - return { ok: false, error: 'Render penceresi yüklenemedi.' }; + if (isMine()) finalizeExport('error', trUi('Render penceresi yüklenemedi: ', 'Could not load the render window: ') + err.message); + return { ok: false, error: trUi('Render penceresi yüklenemedi.', 'Could not load the render window.') }; } + // Cancelled or failed while the window was loading: do not send the job + if (!isMine() || win.isDestroyed()) return { ok: false, error: trUi('Dışa aktarma başlamadan durdu.', 'The export stopped before it started.') }; const ab = audioBuf.buffer.slice(audioBuf.byteOffset, audioBuf.byteOffset + audioBuf.byteLength); exportWin.webContents.send('export:job', { @@ -4982,7 +5019,7 @@ ipcMain.handle('export:frame', async (e, data) => { if (prof) prof.drainMs += performance.now() - td; if (!exportState || exportState.cancel) return { cancel: true }; if (!drained) { - finalizeExport('error', 'Kodlayıcı borusu kapandı. Dışa aktarma durduruldu.'); + finalizeExport('error', trUi('Kodlayıcı borusu kapandı. Dışa aktarma durduruldu.', 'The encoder pipe closed. Export stopped.')); return { cancel: true }; } } @@ -7698,7 +7735,7 @@ function shutdownCleanup() { } app.on('before-quit', (e) => { - if (forceQuitApp || SMOKE) { + if (forceQuitApp || signalQuit || SMOKE) { shutdownCleanup(); return; } diff --git a/src/shared/app-audio-linux.js b/src/shared/app-audio-linux.js index ed5327d..c2c32a4 100644 --- a/src/shared/app-audio-linux.js +++ b/src/shared/app-audio-linux.js @@ -137,7 +137,24 @@ hedef ancak adla yeniden bulunur. İkili adı bildirmeyen istemcilerde uygulamanın kendi verdiği ad kullanılıyor. */ function streamName(s) { - return (s && (s.binary || s.appName)) || ''; + if (!s) return ''; + /* Generic hosts (pacat, python3, electron, java) run many different + apps under one binary. Grouping by binary alone merged them into one + row ("ToneB · 2 processes"), so the key carries the app name too + when it differs from the binary: "pacat|ToneA". A plain binary key + saved by an older version still matches (see belongsTo). */ + const bin = s.binary || ''; + const app = String(s.appName || '').replace(/[\\/|]/g, '-'); + if (!bin) return app; + if (!app || app.toLowerCase() === baseName(bin)) return bin; + return bin + APP_SEP + app; + } + const APP_SEP = '|'; + /* Splits a target key into its binary and optional app name. */ + function splitKey(match) { + const m = String(match || ''); + const i = m.indexOf(APP_SEP); + return i < 0 ? { bin: baseName(m), app: '' } : { bin: baseName(m.slice(0, i)), app: m.slice(i + 1).toLowerCase() }; } /* Ortak süreç biçimi — Windows yardımcısının --list çıktısıyla aynı: @@ -172,7 +189,14 @@ içinden bildiriyor. Ad verilmemişse kimlik. */ function belongsTo(stream, target) { if (!stream || !target) return false; - const want = baseName(target.match || target.name); + const raw = target.match || target.name; + const want = baseName(raw); + if (want && String(raw).indexOf(APP_SEP) >= 0) { + // Binary + app name key: only that app's streams, not the whole host + const k = splitKey(raw); + return baseName(stream.binary) === k.bin + && String(stream.appName || '').replace(/[\\/|]/g, '-').toLowerCase() === k.app; + } if (want) { return baseName(stream.binary) === want || (!stream.binary && baseName(stream.appName) === want); } @@ -299,7 +323,7 @@ const api = { SAMPLE_RATE, CHANNELS, BYTES_PER_FRAME, C_LOCALE_ENV, parseSinkInputsJson, parseSinkInputsText, parseSinkInputs, - streamName, toProcesses, belongsTo, selectStreams, parecArgs, + streamName, splitKey, toProcesses, belongsTo, selectStreams, parecArgs, parseSubscribeLine, PcmMixer, }; diff --git a/src/shared/app-audio.js b/src/shared/app-audio.js index 8cfa0c0..5913d24 100644 --- a/src/shared/app-audio.js +++ b/src/shared/app-audio.js @@ -195,6 +195,14 @@ // ------------------------------------------------------------ hedef eşleme const baseName = (s) => String(s || '').split(/[\\/]/).pop().toLowerCase(); + /* Linux keys can be "binary|app name" (app-audio-linux.js streamName). + A plain saved key ("pacat", "chrome") still finds such a process by its + binary, so targets saved before the split keep working. */ + const sameApp = (name, want) => { + const n = baseName(name); + if (n === want) return true; + return want.indexOf('|') < 0 && n.indexOf('|') > 0 && n.slice(0, n.indexOf('|')) === want; + }; /* Kaydedilmiş hedefi çalışan süreçler arasında bulur. Sıra önemli: önce kaydedilmiş kimlik HÂLÂ aynı uygulamaya aitse o, @@ -218,12 +226,12 @@ if (a.pid) { const byPid = list.find((p) => Number(p.pid) === a.pid); - if (byPid && baseName(byPid.name) === want) { - return { pid: Number(byPid.pid), name: byPid.name, matched: 'pid' }; + if (byPid && sameApp(byPid.name, want)) { + return { pid: Number(byPid.pid), name: baseName(byPid.name) === want ? byPid.name : a.match, matched: 'pid' }; } } - const byName = list.filter((p) => baseName(p.name) === want); + const byName = list.filter((p) => sameApp(p.name, want)); if (!byName.length) return fallback; /* Aynı addan birden çok süreç olabiliyor (tarayıcılar). Ses çıkaranı @@ -231,7 +239,11 @@ const active = byName.filter((p) => p.audible); const pool = active.length ? active : byName; const best = pool.slice().sort((x, y) => (Number(x.pid) || 0) - (Number(y.pid) || 0))[0]; - return { pid: Number(best.pid), name: best.name, matched: active.length ? 'audible' : 'name' }; + /* A legacy plain key ("pacat") must keep capturing EVERY stream of that + binary, so the capture target stays the plain key, not one app's + "pacat|ToneA" key. */ + const name = baseName(best.name) === want ? best.name : a.match; + return { pid: Number(best.pid), name, matched: active.length ? 'audible' : 'name' }; } /* Arayüzde gösterilecek aday listesi. Ses oturumu olan süreçler önce, @@ -368,7 +380,7 @@ WIN_MIN_BUILD, MAC_MIN_MAJOR, MODES, DEFAULT_MODE, support, macMajorFromDarwin, isAppSource, normalizeApp, splitSources, limitExclude, - resolveTarget, candidates, baseName, withoutSelf, + resolveTarget, candidates, baseName, sameApp, withoutSelf, METER_CARRY_MAX, METER_FLOOR_DB, METER_RELEASE_PER_SEC, splitMeterChunk, parseMeterLine, meterLevels, meterFraction, meterBallistics, }; diff --git a/src/shared/export-formats.js b/src/shared/export-formats.js index 61efe4a..f80f16e 100644 --- a/src/shared/export-formats.js +++ b/src/shared/export-formats.js @@ -528,12 +528,33 @@ return { pct: Math.min(100, (d / t) * 100), fps, eta }; } + /* Rejects explicit but unknown export options instead of silently + replacing them with defaults. Omitted (undefined/null/'') values are + fine: the job falls back to its defaults. Returns null when valid, + otherwise { field, value, allowed }. */ + function validateOptions(opts) { + const o = opts || {}; + const given = (v) => v !== undefined && v !== null && v !== ''; + const checks = [ + ['resolution', Object.keys(RESOLUTIONS), (v) => Object.prototype.hasOwnProperty.call(RESOLUTIONS, v)], + ['fps', FPS, (v) => FPS.includes(Number(v))], + ['quality', QUALITIES, (v) => QUALITIES.includes(v)], + ['speed', SPEEDS, (v) => SPEEDS.includes(v)], + ['encoder', ['cpu', 'gpu'], (v) => v === 'cpu' || v === 'gpu'], + ]; + for (const [field, allowed, ok] of checks) { + if (given(o[field]) && !ok(o[field])) return { field, value: o[field], allowed: allowed.slice() }; + } + return null; + } + const api = { RESOLUTIONS, FPS, QUALITIES, SPEEDS, ENCODERS, FORMATS, DEFAULT_FORMAT, format, extensions, encodersFor, pickEncoder, available, videoArgs, audioArgs, buildArgs, probeArgs, sequencePattern, takesYuv420, PROFILES, profile, matchProfile, applyProfile, chapterPlan, chapterFileName, DURATION_MODES, MAX_SECONDS, resolveDuration, sanitizeFileName, outputName, partialPath, progressStats, + validateOptions, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 2b73a8f..d87d15a 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2976,7 +2976,7 @@ 'Kuyruğa Ekle': 'Add to Queue', 'Birden Çok Ses Ekle': 'Add Several Audio Files', 'Kuyruğu Başlat': 'Start Queue', 'Kuyruğu Durdur': 'Stop Queue', 'Bitenleri Temizle': 'Clear Finished', 'Bekliyor': 'Waiting', 'Çalışıyor': 'Running', 'Bitti': 'Done', 'İptal edildi': 'Cancelled', - 'Yeniden Dene': 'Retry', 'Kuyruk boş.': 'The queue is empty.', + 'Yeniden Dene': 'Retry', 'Kuyruk boş.': 'The queue is empty.', 'Dışa Aktarma Kuyruğu': 'Export Queue', 'Bazı işler eklenemedi (en çok 200 iş, geçerli bir yol gerekir).': 'Some jobs could not be added (at most 200 jobs, and each needs a valid path).', 'Her iş eklendiği andaki sahneyi ve ayarları kullanır; biri hata verse de sonraki çalışır. Bir sahneyi birden çok şarkıya ya da bir şarkıyı birden çok çözünürlüğe aynı klasöre çıkarmak için.': 'Each job uses the scene and settings from the moment it was added; if one fails, the next still runs. Use it to render one scene across several songs, or one song at several resolutions, into the same folder.', 'Instagram kare — 1080×1080': 'Instagram square — 1080×1080', 'Twitch / OBS bindirme — WebM alfa': 'Twitch / OBS overlay — WebM alpha', diff --git a/src/shared/mcp.js b/src/shared/mcp.js index 62c11d8..ae31db2 100644 --- a/src/shared/mcp.js +++ b/src/shared/mcp.js @@ -2068,6 +2068,10 @@ } const fmt = FX ? FX.format((args && args.format) || FX.DEFAULT_FORMAT) : null; if (FX && !fmt) return fail('Unknown format: ' + (args && args.format)); + /* Invalid values used to be replaced silently ('8k', 144 fps rendered + as 1080p60). Reject them with the allowed values instead. */ + const badOpt = FX && FX.validateOptions ? FX.validateOptions(args) : null; + if (badOpt) return fail('Invalid ' + badOpt.field + ': ' + JSON.stringify(badOpt.value) + '. Allowed: ' + badOpt.allowed.join(', ') + '.'); const outBad = outPathError(ctx, args && args.outputPath, [fmt ? fmt.ext : 'mp4'], args && args.overwrite); if (outBad) return fail('outputPath: ' + outBad); return Promise.resolve(ctx.startExport(args || {})).then(function (r) { return r && r.ok === false ? r : Object.assign({ ok: true }, r || {}); }); diff --git a/tests/app-audio-linux.test.js b/tests/app-audio-linux.test.js index df82a75..d315343 100644 --- a/tests/app-audio-linux.test.js +++ b/tests/app-audio-linux.test.js @@ -91,7 +91,8 @@ test('özellik değerindeki kaçışlı tırnak çözülür', () => { test('liste Windows yardımcısıyla aynı biçimde', () => { const p = L.toProcesses(L.parseSinkInputs(JSONOUT)); assert.deepStrictEqual(p, [ - { pid: 553, name: 'pacat', label: 'toneA', audible: true }, + // pacat hosts many apps: the key carries the app name (QA finding) + { pid: 553, name: 'pacat|toneA', label: 'toneA', audible: true }, { pid: 555, name: 'othertone', label: 'othertone', audible: true }, ]); }); diff --git a/tests/qa-linux-findings.test.js b/tests/qa-linux-findings.test.js new file mode 100644 index 0000000..2c37799 --- /dev/null +++ b/tests/qa-linux-findings.test.js @@ -0,0 +1,197 @@ +'use strict'; +/* Linux QA findings (export after cancel, follow recording, EN strings, + app-audio grouping, export argument validation, quit on SIGTERM). */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const mcp = require('../src/shared/mcp'); +const F = require('../src/shared/export-formats.js'); +const L = require('../src/shared/app-audio-linux.js'); +const A = require('../src/shared/app-audio.js'); + +const ROOT = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); +const MAIN = read('src/main/main.js'); +const jobSrc = () => { + const a = MAIN.indexOf('async function startExportJob('); + const b = MAIN.indexOf("ipcMain.handle('export:start'", a); + assert.ok(a > 0 && b > a); + return MAIN.slice(a, b); +}; + +test('export: ffmpeg/window handlers are bound to their own job', () => { + const s = jobSrc(); + assert.match(s, /const job = exportState;/); + assert.match(s, /const isMine = \(\) => exportState === job;/); + assert.match(s, /if \(!isMine\(\) \|\| code == null\) return;/); + assert.match(s, /proc\.on\('exit', onFfClosed\);/); + assert.match(s, /proc\.on\('close', onFfClosed\);/); + assert.doesNotMatch(s, /ffmpegProc\.on\('(exit|close|error)'/); + // The old window's late 'closed' must not clear the new job's window + assert.match(s, /if \(exportWin === win\) exportWin = null;/); +}); + +test('export: a late close from a cancelled job does not finalize the next one', () => { + const { EventEmitter } = require('events'); + let exportState = null; + const finalized = []; + const finalizeExport = (st) => { finalized.push(st); exportState = null; }; + // Same guard as startExportJob + const start = () => { + const proc = new EventEmitter(); + exportState = { cancel: false }; + const job = exportState; + const isMine = () => exportState === job; + const onFfClosed = (code) => { + if (!isMine() || code == null) return; + if (code === 0) finalizeExport('done'); + else if (exportState.cancel) finalizeExport('cancelled'); + else finalizeExport('error'); + }; + proc.on('exit', onFfClosed); + proc.on('close', onFfClosed); + return proc; + }; + const old = start(); + exportState.cancel = true; + finalizeExport('cancelled'); // renderer reports the cancel + start(); + const second = exportState; + old.emit('exit', 255); + old.emit('close', 255); + assert.strictEqual(exportState, second, 'new export survived'); + assert.deepStrictEqual(finalized, ['cancelled']); +}); + +test('export: main-process errors are localized with trUi', () => { + const s = jobSrc(); + for (const tr of ['Zaten bir dışa aktarma sürüyor.', 'Ses dosyası bulunamadı.', 'Render penceresi yüklenemedi.']) { + assert.ok(s.includes("trUi('" + tr + "'"), tr); + } +}); + +test('MCP sv_start_export rejects invalid resolution/fps instead of rendering 1080p60', async () => { + const started = []; + const c = { + locale: () => 'en', + getConfig: () => ({ mcp: { enabled: true, mode: 'write' }, scenes: [] }), + setConfig: () => {}, + startExport: (o) => { started.push(o); return { ok: true }; }, + }; + const abs = (n) => (process.platform === 'win32' ? 'C:\\Users\\me\\' : '/home/me/') + n; + const r1 = await mcp.callTool('sv_start_export', { audioPath: abs('a.mp3'), outputPath: abs('o.mp4'), resolution: '8k' }, c); + assert.strictEqual(r1.ok, false); + assert.match(r1.error, /resolution.*8k.*1080p/); + const r2 = await mcp.callTool('sv_start_export', { audioPath: abs('a.mp3'), outputPath: abs('o.mp4'), fps: 144 }, c); + assert.strictEqual(r2.ok, false); + assert.match(r2.error, /fps.*144/); + const r3 = await mcp.callTool('sv_start_export', { audioPath: abs('a.mp3'), outputPath: abs('o.mp4'), resolution: '2160p', fps: 30 }, c); + assert.strictEqual(r3.ok, true); + assert.strictEqual(started.length, 1); +}); + +test('validateOptions: omitted values pass, unknown ones are reported', () => { + assert.strictEqual(F.validateOptions({}), null); + assert.strictEqual(F.validateOptions({ resolution: '1080p', fps: '60', quality: 'high', speed: 'fast', encoder: 'gpu' }), null); + assert.strictEqual(F.validateOptions({ quality: 'ultra' }).field, 'quality'); + assert.strictEqual(F.validateOptions({ speed: 'slow' }).field, 'speed'); + assert.strictEqual(F.validateOptions({ encoder: 'nvenc' }).field, 'encoder'); +}); + +test('timeline-follow recording reacts to external config changes', () => { + const admin = read('src/admin/admin.js'); + const a = admin.indexOf('window.api.onExternalConfig('); + const body = admin.slice(a, admin.indexOf('\n });', a)); + assert.match(body, /SVRecordPanel\.ensureFollow\(!!\(cfg\.recording && cfg\.recording\.followTimeline\)\)/); +}); + +function loadEnglish() { + const doc = { documentElement: { lang: '' }, readyState: 'complete', title: '', body: {}, addEventListener: () => {} }; + const win = { + navigator: { languages: ['en-US'], language: 'en-US' }, + localStorage: { getItem: () => 'en', setItem: () => {} }, + alert: () => {}, confirm: () => {}, document: doc, + MutationObserver: function () { this.observe = () => {}; }, + Node: { TEXT_NODE: 3, ELEMENT_NODE: 1 }, + }; + win.window = win; + vm.runInContext(read('src/shared/i18n.js'), vm.createContext(win), { filename: 'i18n.js' }); + return win.SVI18n; +} + +test('EN UI: export queue heading reads "Export Queue", toasts are translated', () => { + const i18n = loadEnglish(); + assert.ok(read('src/admin/admin.js').includes("text: 'Dışa Aktarma Kuyruğu'")); + assert.strictEqual(i18n.t('Dışa Aktarma Kuyruğu'), 'Export Queue'); + assert.strictEqual(i18n.t('Kaydedilemedi: x'), 'Could not save: x'); + assert.match(read('src/admin/admin.js'), /host\.textContent = window\.SVI18n && window\.SVI18n\.t \? window\.SVI18n\.t\(String\(message\)\) : message;/); +}); + +test('Linux app audio: one binary hosting two apps gives two selectable rows', () => { + const streams = [ + { index: 1, pid: 10, binary: 'pacat', appName: 'ToneA' }, + { index: 2, pid: 11, binary: 'pacat', appName: 'ToneB' }, + { index: 3, pid: 12, binary: 'firefox', appName: 'Firefox' }, + ]; + const procs = L.toProcesses(streams); + const rows = A.candidates(procs); + assert.deepStrictEqual(rows.map((r) => [r.label, r.count]).sort(), [['Firefox', 1], ['ToneA', 1], ['ToneB', 1]]); + const a = rows.find((r) => r.label === 'ToneA'); + assert.deepStrictEqual(L.selectStreams(streams, { match: a.match }, 'include'), [1]); + assert.deepStrictEqual(L.selectStreams(streams, { match: a.match }, 'exclude'), [2, 3]); + // Binary equal to app name keeps the plain key; old saved plain keys still match + assert.strictEqual(procs.find((p) => p.pid === 12).name, 'firefox'); + assert.deepStrictEqual(L.selectStreams(streams, { match: 'pacat' }, 'include'), [1, 2]); + assert.strictEqual(A.resolveTarget({ kind: 'app', match: 'pacat' }, procs, 'linux').pid, 10); + assert.strictEqual(A.resolveTarget({ kind: 'app', match: 'pacat|ToneB' }, procs, 'linux').pid, 11); +}); + +test('SIGTERM/SIGINT quit skips the confirm prompt and has a forced exit', () => { + const a = MAIN.indexOf('function quitOnSignal('); + assert.ok(a > 0); + const s = MAIN.slice(a, MAIN.indexOf('\n}', a)); + assert.match(s, /forceQuitApp = true;/); + assert.match(s, /exportState\.cancel = true/); + assert.match(s, /setTimeout\(\(\) => \{ try \{ app\.exit\(0\); \} finally \{ process\.exit\(0\); \} \}, 5000\);/); + assert.doesNotMatch(s, /unref|showMessageBoxSync/); + // Chromium's own SIGTERM handler (installed at startup) closes windows + // without telling Node; handlers are re-registered after ready. + assert.match(MAIN, /process\.removeListener\(sig, quitOnSignal\);\s*process\.on\(sig, quitOnSignal\);/); + assert.match(MAIN, /app\.whenReady\(\)\.then\(\(\) => \{ installSignalHandlers\(\);/); + // Declared before the admin window's close handler and checked there + assert.ok(a < MAIN.indexOf("adminWin.on('close'")); + assert.match(MAIN, /adminWin\.on\('close', \(e\) => \{\s*if \(forceQuitApp \|\| signalQuit \|\| SMOKE\) return;/); + assert.match(MAIN, /app\.on\('before-quit', \(e\) => \{\s*if \(forceQuitApp \|\| signalQuit \|\| SMOKE\)/); +}); + +test('Linux app audio: a legacy plain key still captures every stream of its binary', () => { + const streams = [ + { index: 1, pid: 10, binary: 'pacat', appName: 'ToneA' }, + { index: 2, pid: 11, binary: 'pacat', appName: 'ToneB' }, + { index: 3, pid: 12, binary: 'python3', appName: 'Synth' }, + ]; + const procs = L.toProcesses(streams); + for (const [key, want] of [['pacat', [1, 2]], ['python3', [3]]]) { + const t = A.resolveTarget({ kind: 'app', match: key, pid: 0 }, procs, 'linux'); + assert.strictEqual(t.name, key, 'capture target stays the plain key'); + assert.deepStrictEqual(L.selectStreams(streams, { pid: t.pid, match: t.name }, 'include'), want); + } + // Saved pid hint path too + const byPid = A.resolveTarget({ kind: 'app', match: 'pacat', pid: 11 }, procs, 'linux'); + assert.strictEqual(byPid.name, 'pacat'); + // New per-app keys stay per-app + assert.strictEqual(A.resolveTarget({ kind: 'app', match: 'pacat|ToneB' }, procs, 'linux').name, 'pacat|ToneB'); +}); + +test('picker: a legacy plain selection matching running apps is not shown as "not running"', () => { + assert.strictEqual(A.sameApp('pacat|ToneA', 'pacat'), true); + assert.strictEqual(A.sameApp('pacat|ToneA', 'pacat|toneb'), false); + assert.strictEqual(A.sameApp('firefox', 'pacat'), false); + const admin = read('src/admin/admin.js'); + const a = admin.indexOf('for (const s of selectedApps) {'); + const body = admin.slice(a, admin.indexOf("'çalışmıyor'", a)); + assert.match(body, /apps\.filter\(\(a\) => AA\.sameApp\(a\.match, AA\.baseName\(s\.match\)\)\)/); + assert.match(body, /if \(live\.length\) \{/); +});