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
[](https://github.com/CaYatur/SoundVisualizer/releases)
[](LICENSE)
[](#download)
-[](#tests)
+[](#tests)
[](https://www.electronjs.org/)
@@ -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ü**.
[](https://github.com/CaYatur/SoundVisualizer/releases)
[](LICENSE)
[](#hızlı-başlangıç)
-[](#testler)
+[](#testler)
[](https://www.electronjs.org/)
@@ -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\) \{/);
+});