diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe219c..fd19f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ GitHub. The full reasoning and the measurements behind each change are in [ROADM CPU choice and the speed balance stay yours. The profile is read from the settings themselves, so changing any of them by hand shows "Custom", and every setting stays available underneath. MCP's `sv_start_export` takes a `profile` too, with explicit arguments taking precedence. +- **A batch queue [526].** Queue the current song with the current settings, or several audio + files at once into one folder, and start the queue. Each job keeps the scene and settings from + the moment it was added, so you can change the scene and queue the next variation; one song at + three resolutions gets three distinctly named files. A failing job is marked and the next one + runs. Each row shows its status, failed or cancelled jobs can be retried, and Stop cancels the + running job without leaving a half-written file. - **Split a long show at the timeline markers [527].** One render is cut at forced keyframes into a folder of files named after the markers ("01 - Intro.mp4", "02 - Drop.mp4"), so feedback layers like MilkDrop run continuously across the cuts instead of restarting cold in each part. diff --git a/README.md b/README.md index 4b872c9..cc1745d 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-3145%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3151%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -937,7 +937,9 @@ the fixtures on the left take the colours on the left of the screen. time, the timeline or a number of bars; the file is named and tagged with the track, scene and BPM, and a cancelled export leaves no half-written file. One-click profiles cover YouTube, Reels / Shorts, Instagram, a Twitch overlay with alpha and a ProRes master, and a long show can be - split at the timeline's markers into files named after them, from a single render. + split at the timeline's markers into files named after them, from a single render. A batch + queue renders one scene across many songs, or one song at several sizes, and keeps going past + a failed job. - **Live recording** of the output exactly as it appears — with the live audio, modulation, transitions and effects — to MP4 or WebM, on one key. - **GIF export** with two-pass palette generation, because one pass bands visibly. @@ -1717,7 +1719,7 @@ npm test npm start -- --smoke ``` -**3145 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3151 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 9bb70b6..6e2f3ac 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-3145%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3151%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -937,7 +937,8 @@ ekranın solundaki renkleri alıyor. etiketleniyor, iptal edilen dışa aktarım yarım dosya bırakmıyor. Tek tıklık profiller YouTube, Reels / Shorts, Instagram, alfalı Twitch bindirmesi ve ProRes ana kopyayı kapsıyor; uzun bir gösteri tek render'dan zaman çizelgesinin işaretçilerinde, onların adlarını taşıyan dosyalara - bölünebiliyor. + bölünebiliyor. Toplu kuyruk bir sahneyi birçok şarkıya ya da bir şarkıyı birkaç boyuta + çıkarıyor ve hata veren bir işte durmuyor. - Çıkışın göründüğü gibi **canlı kaydı** — canlı ses, modülasyon, geçişler ve efektlerle — MP4 ya da WebM'e, tek tuşla. - Tek geçiş gözle görülür bantlandığı için iki geçişli palet üretimli **GIF dışa aktarma**. @@ -1721,7 +1722,7 @@ npm test npm start -- --smoke ``` -**3145 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3151 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 02afdf9..fc785c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,7 +93,7 @@ hands, and a row only moves up once it ships. |---|:--:|---| | Per-app audio capture on macOS and Linux | v3.1.6 | On `main` (#514, #517). Linux verified in WSLg with PulseAudio; macOS written on ScreenCaptureKit and compiled in CI, never run on a Mac. Windows shipped in v3.1.3 | | Live level meter per application | v3.1.6 | Done on `main` (#516): a moving dB-scale bar per application, streamed only while the picker is on screen | -| Comprehensive video export | v3.1.6 | Formats, alpha, frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522), chapters (#527) done; queue and the two render modes in progress | +| Comprehensive video export | v3.1.6 | Formats, alpha, frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522), chapters (#527), batch queue (#526) done; the two render modes in progress | | Broadcast layout editor | v3.1.7 | Not started | | NDI | v3.1.8 | Deferred for licence reasons, not difficulty — see "Not done, and why" | | Redundancy / genlock | v3.2.0 | Not started | @@ -131,7 +131,7 @@ npm test npm start -- --smoke ``` -- **3145 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3151 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 @@ -3352,6 +3352,27 @@ Export is about three times faster for MP4 and WebM, with no change to the pictu - A saved "GPU" preference is no longer rewritten to "CPU" on a machine without one: the export runs on the CPU and the box says so. - The bench-only options, a raw frame dump and a forced RGBA path, are a separate argument that neither the panel nor MCP can set, because the dump writes a file. +### Batch queue (#526) · done + +- **Where it lives.** The queue is in the main process and runs jobs one after another. Each job carries a copy of the scene configuration taken when it was added, and `startExportJob` renders that copy rather than the live config. You can therefore change the scene and queue the next variation. +- **Adding jobs.** + - "Add to Queue" adds the chosen song with the current settings. + - "Add Several Audio Files" adds one job per file into a chosen folder. + - Names follow "Track - Scene - 1080p". A name already on disk or in the queue gets " (2)", so one song at three resolutions, or ten songs into one folder, never overwrite each other. + - The path rule is the same as for a single export. + - The queue is capped at 200 jobs. +- **Failure handling.** + - A job that fails is marked with its message, and the queue moves on. + - A job that never started gives back its completion waiter. Otherwise the next job's result would have been reported against it. + - Failed or cancelled jobs can be retried. +- **Stopping.** Stop cancels the running job and leaves the rest waiting. Quitting the app stops the queue before cancelling the running job, so the next job does not start during shutdown. +- **Single exports.** A single export is refused while the queue runs. +- **Measured end to end:** + - **Bench queue mode.** Two jobs with the same name came out as `q1.mp4` and `q1 (2).mp4`. A job with a missing audio file failed with "Ses dosyası bulunamadı." and the WebM and ProRes jobs after it ran. The scene was changed after the jobs were added, and the first two videos still decode to identical frames (`framemd5`). + - **Stopping mid-run.** The running job was cancelled, the rest stayed waiting, and the folder was empty. A `.partial` first seemed to remain, but only because the bench exited with `app.exit()`, which skips the delayed delete; the driver now flushes pending deletes before it exits. + - **Panel.** Driven by its own buttons over CDP, the rows went Waiting → Running → Done, and the button switched between Start and Stop. After the queue ended, "Export to Video" stayed disabled: a queue update arriving after the last job's done event re-marked the panel as busy. The panel now releases the state it set when the queue stops. +- **Not in MCP yet.** `sv_start_export` remains one job at a time. + ### Chapters at the timeline markers (#527) · done - **One render, cut afterwards.** The markers inside the export become forced keyframes, and ffmpeg's segment muxer cuts the single render at them. Rendering each chapter on its own would start MilkDrop and other feedback layers cold at every boundary, so the cut would show. diff --git a/src/admin/admin.css b/src/admin/admin.css index cfc84cc..f2170ea 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -1559,3 +1559,15 @@ input[type="number"]::-webkit-inner-spin-button { font-weight: 800; letter-spacing: .01em; } + +/* Dışa aktarma kuyruğu (#526) */ +.export-queue { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.export-queue-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card2); } +.export-queue-row .q-main { flex: 1; min-width: 0; } +.export-queue-row .q-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.export-queue-row .q-meta { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.export-queue-row .q-status { font-size: 12px; white-space: nowrap; } +.export-queue-row .q-actions { display: flex; gap: 4px; } +.export-queue-row.q-running { border-color: var(--accent); } +.export-queue-row.q-done .q-status { color: var(--ok, #4caf50); } +.export-queue-row.q-error .q-status { color: var(--err, #ff6b6b); } diff --git a/src/admin/admin.js b/src/admin/admin.js index 777e7cd..7e9a0d8 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -595,6 +595,8 @@ return audioFileCtrl(def); case 'exportpanel': return exportPanelCtrl(def); + case 'exportqueue': + return exportQueueCtrl(def); case 'lightingpanel': return lightingPanelCtrl(def); case 'lightinggeneralpanel': @@ -1687,6 +1689,133 @@ return el('div', { class: 'ctrl' }, [btn, name]); } + // --- Video dışa aktarma kuyruğu (#526) --- + /* Kuyruk ana süreçte; panel yalnız durumu çiziyor ve komut gönderiyor. + Liste kendi kutusunda yeniden çiziliyor, kartın geri kalanı değil. */ + let exportQueueSt = { running: false, items: [] }; + let exportQueueDir = ''; + let exportByQueue = false; // panelin "çalışıyor" hâlini kuyruk mu açtı + const QUEUE_STATUS = { + waiting: ['Bekliyor', ''], running: ['Çalışıyor', 'play'], done: ['Bitti', 'check'], + error: ['Hata', 'warning'], cancelled: ['İptal edildi', 'stop'], + }; + + /* Kuyruk işinin önerilen adı: "Parça - Sahne - 1080p.mp4". Çözünürlük + adda: aynı şarkı üç çözünürlükte aynı klasöre gidebilsin. */ + function queueItemName(audioPath, fmt) { + const FX = window.SVExportFormats; + const title = String(audioPath || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, ''); + const e = cfg.export || {}; + const base = FX ? FX.outputName({ title, scene: activeSceneName() }, '') : title; + const name = base + ' - ' + (e.resolution || '1080p'); + return (FX ? FX.sanitizeFileName(name, 'export') : name) + '.' + (fmt ? fmt.ext : 'mp4'); + } + + function queueItemOpts() { + const e = cfg.export || {}; + const dur = exportDuration(); + const fmt = exportFormat(); + return { + format: fmt ? fmt.id : 'mp4-h264', + resolution: e.resolution, fps: e.fps, quality: e.quality, encoder: e.encoder, speed: e.speed, + duration: { mode: dur.mode, seconds: dur.seconds, bars: dur.bars, bpm: dur.bpm }, + chapters: !!e.chapters && (!fmt || fmt.kind === 'video'), + sceneName: activeSceneName(), + }; + } + + async function queueAddFiles(files) { + if (!files || !files.length) return; + const dir = await window.api.pickExportFolder(exportQueueDir || undefined); + if (!dir) return; + exportQueueDir = dir; + push(true); // eklenen işler sahnenin şu anki hâlini dondursun + const fmt = exportFormat(); + const sep = dir.indexOf('\\') >= 0 ? '\\' : '/'; + const items = files.map((a) => ({ + audioPath: a, outputPath: dir.replace(/[\\/]+$/, '') + sep + queueItemName(a, fmt), opts: queueItemOpts(), + })); + const r = await window.api.exportQueueAdd(items); + if (r && r.added && r.added.length < items.length) { + P().toast(tr('Bazı işler eklenemedi (en çok 200 iş, geçerli bir yol gerekir).'), 'warn'); + } + } + + function renderExportQueue() { + const box = $('exportQueueList'); + if (!box) return; + box.innerHTML = ''; + const st = exportQueueSt || { items: [] }; + if (!st.items.length) { + box.appendChild(el('div', { class: 'studio-note dim-hint', text: 'Kuyruk boş.' })); + } + for (const it of st.items) { + const [label, icon] = QUEUE_STATUS[it.status] || [it.status, '']; + const meta = [it.format, it.resolution, it.fps ? it.fps + ' fps' : '', it.audioName].filter(Boolean).join(' · '); + const actions = []; + if (it.status === 'error' || it.status === 'cancelled') { + actions.push(el('button', { class: 'btn ghost small', type: 'button', icon: 'refresh', text: 'Yeniden Dene', + onclick: () => window.api.exportQueueRetry(it.id) })); + } + if (it.status !== 'running') { + actions.push(el('button', { class: 'btn ghost small', type: 'button', icon: 'trash', text: 'Kaldır', + onclick: () => window.api.exportQueueRemove(it.id) })); + } + const row = el('div', { class: 'export-queue-row q-' + it.status, title: it.message || it.output || it.outputPath }, [ + el('div', { class: 'q-main' }, [ + el('div', { class: 'q-name', text: it.name }), + el('div', { class: 'q-meta', text: meta }), + ]), + el('div', { class: 'q-status', icon, text: label }), + el('div', { class: 'q-actions' }, actions), + ]); + box.appendChild(row); + } + const run = $('exportQueueRun'); + if (run) { + const waiting = st.items.some((x) => x.status === 'waiting'); + if (window.SVIcons) window.SVIcons.set(run, st.running ? 'stop' : 'play', st.running ? 'Kuyruğu Durdur' : 'Kuyruğu Başlat'); + run.className = 'btn ' + (st.running ? 'danger' : 'primary') + ' small'; + run.disabled = !st.running && !waiting; + } + } + + function exportQueueCtrl() { + const add = el('button', { + class: 'btn ghost small', type: 'button', icon: 'plus', text: 'Kuyruğa Ekle', + onclick: async () => { + if (!exportAudioPath) { setExportStatus('Önce bir ses dosyası seçin.', 'err'); return; } + await queueAddFiles([exportAudioPath]); + }, + }); + const addMany = el('button', { + class: 'btn ghost small', type: 'button', icon: 'music', text: 'Birden Çok Ses Ekle', + onclick: async () => queueAddFiles(await window.api.pickExportAudioMulti()), + }); + const run = el('button', { + class: 'btn primary small', type: 'button', id: 'exportQueueRun', icon: 'play', text: 'Kuyruğu Başlat', + onclick: async () => { + if (exportQueueSt.running) { await window.api.exportQueueStop(); return; } + push(true); + const r = await window.api.exportQueueStart(); + if (r && r.ok === false && r.error && r.error !== 'running') setExportStatus(r.error, 'err', 'warning'); + }, + }); + const clear = el('button', { + class: 'btn ghost small', type: 'button', icon: 'check-circle', text: 'Bitenleri Temizle', + onclick: () => window.api.exportQueueClear(), + }); + const list = el('div', { class: 'export-queue', id: 'exportQueueList' }); + const box = el('div', { class: 'ctrl' }, [ + el('label', { class: 'lbl', text: 'Kuyruk' }), + 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, + ]); + setTimeout(renderExportQueue, 0); + return box; + } + // --- Video dışa aktarma: çalıştır düğmesi + ilerleme --- function exportPanelCtrl() { const runBtn = el('button', { @@ -3484,6 +3613,7 @@ ], }, { type: 'exportpanel', tail: true }, + { type: 'exportqueue', tail: true }, ], }, ]; @@ -6395,6 +6525,19 @@ if (d.eta != null) line += ' · kalan ' + fmtClock(d.eta); setExportStatus(line); }); + // Kuyruk durumu (#526): liste kendi kutusunda tazeleniyor + try { exportQueueSt = (await window.api.exportQueueState()) || exportQueueSt; } catch { /* eski ana süreç */ } + if (window.api.onExportQueue) { + window.api.onExportQueue((d) => { + exportQueueSt = d || { running: false, items: [] }; + /* Kuyruk koşarken ilerleme çubuğu ve İptal (o anki işi iptal eder) + görünür. Kuyruk bitince açtığı durumu kapatıyor: son işin bitiş + olayından SONRA gelen bildirim düğmeyi kilitli bırakıyordu. */ + if (exportQueueSt.running && !exporting) { exporting = true; exportByQueue = true; setExportUI(true); } + else if (!exportQueueSt.running && exportByQueue) { exporting = false; exportByQueue = false; setExportUI(false); } + renderExportQueue(); + }); + } window.api.onExportDone((d) => { exporting = false; setExportUI(false); diff --git a/src/main/main.js b/src/main/main.js index f68a922..7e0e081 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -4256,6 +4256,147 @@ let exportWin = null; let ffmpegProc = null; let exportState = null; // { outputPath, ffErr } — aktifken dolu, biter bitmez null let lastExportProfile = null; // son ölçümlü işin aşama süreleri (#531) +const exportDoneWaiters = []; // finalizeExport sonucu bekleyenler (kuyruk) + +/* DIŞA AKTARMA KUYRUĞU (#526). + + "Bir sahne on şarkıya", "bir şarkı üç çözünürlükte": işler sırayla, + biri hata verse de sonraki koşuyor. Her iş eklendiği anın sahne + yapılandırmasını taşıyor. Kuyruk bellekte; uygulama kapanınca biter. + Panel her değişikliği `export-queue` ile görüyor. */ +const exportQueue = { items: [], running: false, stopping: false, seq: 0 }; +const QUEUE_MAX = 200; + +function queueState() { + return { + running: exportQueue.running, + items: exportQueue.items.map((it) => ({ + id: it.id, name: path.basename(it.outputPath), outputPath: it.outputPath, audioName: path.basename(it.audioPath), + format: it.opts.format, resolution: it.opts.resolution, fps: it.opts.fps, + status: it.status, message: it.message || '', output: it.output || '', + })), + }; +} +function queueNotify() { notifyAdmin('export-queue', queueState()); } + +/* Aynı adlı çıktı kuyrukta ya da diskte varsa "(2)" eklenir: on şarkı aynı + klasöre giderken biri ötekinin üstüne yazmasın. */ +function queueUniquePath(p) { + const taken = (q) => fs.existsSync(q) || exportQueue.items.some((it) => it.status !== 'done' && it.outputPath === q); + if (!taken(p)) return p; + const ext = path.extname(p); + const stem = p.slice(0, p.length - ext.length); + for (let n = 2; n < 1000; n++) { + const q = stem + ' (' + n + ')' + ext; + if (!taken(q)) return q; + } + return p; +} + +function queueAdd(list) { + const added = []; + for (const raw of Array.isArray(list) ? list : []) { + if (exportQueue.items.length >= QUEUE_MAX) break; + const r = raw || {}; + const opts = Object.assign({}, r.opts || {}); + const fmt = exportFormats.format(opts.format || exportFormats.DEFAULT_FORMAT); + if (!fmt || typeof r.audioPath !== 'string' || !path.isAbsolute(r.audioPath)) continue; + if (typeof r.outputPath !== 'string' || require('../shared/mcp').badOutPath(r.outputPath, [fmt.ext])) continue; + const item = { + id: 'q' + (++exportQueue.seq), + audioPath: r.audioPath, + outputPath: queueUniquePath(r.outputPath), + opts, + // Sahne o anki hâliyle donduruluyor + cfg: JSON.parse(JSON.stringify(currentConfig || {})), + status: 'waiting', + }; + exportQueue.items.push(item); + added.push(item.id); + } + queueNotify(); + return { ok: true, added }; +} + +async function queueRun() { + if (exportQueue.running) return { ok: false, error: 'running' }; + exportQueue.running = true; + exportQueue.stopping = false; + queueNotify(); + try { + for (;;) { + if (exportQueue.stopping) break; + const item = exportQueue.items.find((it) => it.status === 'waiting'); + if (!item) break; + // Elle başlatılmış bir iş sürüyorsa bitmesini bekle + while (exportState) await new Promise((r) => setTimeout(r, 300)); + item.status = 'running'; + item.message = ''; + queueNotify(); + let resolveDone = null; + const done = new Promise((resolve) => { resolveDone = resolve; exportDoneWaiters.push(resolve); }); + let res; + try { + res = await startExportJob(Object.assign({}, item.opts, { audioPath: item.audioPath, outputPath: item.outputPath }), null, { cfg: item.cfg }); + } catch (err) { + res = { ok: false, error: (err && err.message) || String(err) }; + } + if (!res || !res.ok) { + // Başlamadı: bekleyeni geri al, sonraki işe geç + const i = exportDoneWaiters.indexOf(resolveDone); + if (i >= 0) exportDoneWaiters.splice(i, 1); + item.status = 'error'; + item.message = (res && res.error) || 'error'; + queueNotify(); + continue; + } + const out = await done; + item.status = out.status === 'done' ? 'done' : (out.status === 'cancelled' ? 'cancelled' : 'error'); + item.message = out.message || ''; + item.output = out.output || ''; + queueNotify(); + } + } finally { + exportQueue.running = false; + exportQueue.stopping = false; + // Durdurulan kuyrukta koşan iş iptal edildi; kalanlar bekliyor + queueNotify(); + } + return { ok: true }; +} + +function queueStop() { + if (!exportQueue.running) return { ok: true }; + exportQueue.stopping = true; + if (exportState) exportState.cancel = true; + queueNotify(); + return { ok: true }; +} + +function queueRemove(id) { + const i = exportQueue.items.findIndex((it) => it.id === id && it.status !== 'running'); + if (i >= 0) exportQueue.items.splice(i, 1); + queueNotify(); + return { ok: i >= 0 }; +} + +function queueClearFinished() { + exportQueue.items = exportQueue.items.filter((it) => it.status === 'waiting' || it.status === 'running'); + queueNotify(); + return { ok: true }; +} + +/* Bitmemiş ya da hatalı işi yeniden sıraya al. */ +function queueRetry(id) { + const it = exportQueue.items.find((x) => x.id === id); + if (it && it.status !== 'running' && it.status !== 'waiting') { + it.status = 'waiting'; + it.message = ''; + it.outputPath = queueUniquePath(it.outputPath); + } + queueNotify(); + return { ok: !!it }; +} /* Yarım kalan dışa aktarım dosyaları. ffmpeg öldürülünce Windows dosya tutamacını hemen bırakmayabilir; silme süreç kapanınca, gerekirse birkaç @@ -4474,8 +4615,11 @@ ipcMain.handle('export:pick-output', async (e, defaultName, formatId) => { kare özeti, ham kare dökümü ve karşılaştırma için RGBA yolu. Panel ve MCP yalnız `opts` verebiliyor; dosya yazan `dumpRaw` gibi bir seçeneğin oradan açılamaması için ikisi ayrı parametre. */ -async function startExportJob(opts, bench) { +async function startExportJob(opts, bench, extra) { bench = bench || null; + /* 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.' }; opts = opts || {}; const audioPath = opts.audioPath; @@ -4559,7 +4703,7 @@ async function startExportJob(opts, bench) { const audioSec = await audioDurationSec(ff, audioPath); const durSpec = opts.duration && typeof opts.duration === 'object' ? opts.duration : { mode: 'audio' }; const TL = require('../shared/timeline.js'); - const tlCfg = (currentConfig && currentConfig.timeline) || {}; + const tlCfg = (jobCfg && jobCfg.timeline) || {}; let tempoMap = null; try { tempoMap = Number(durSpec.bpm) > 0 @@ -4607,8 +4751,8 @@ async function startExportJob(opts, bench) { } /* Sahne adı meta veriye (#528): panel gönderiyor; MCP vermezse etkin sahne. */ let sceneName = typeof opts.sceneName === 'string' ? opts.sceneName.slice(0, 120) : ''; - if (!sceneName && currentConfig && currentConfig._activeSceneId && Array.isArray(currentConfig.scenes)) { - const sc = currentConfig.scenes.find((x) => x && x.id === currentConfig._activeSceneId); + if (!sceneName && jobCfg && jobCfg._activeSceneId && Array.isArray(jobCfg.scenes)) { + const sc = jobCfg.scenes.find((x) => x && x.id === jobCfg._activeSceneId); if (sc && typeof sc.name === 'string') sceneName = sc.name.slice(0, 120); } const bpmMeta = Number(durSpec.bpm) > 0 ? Number(durSpec.bpm) @@ -4715,7 +4859,7 @@ async function startExportJob(opts, bench) { width: w, height: h, fps, - cfg: currentConfig || {}, + cfg: jobCfg || {}, /* Dosyanın adı, kapağı ve — takip açıksa — kütüphane sözü. Normal söz dosyası kütüphaneyle değiştirilmez. */ track, @@ -4727,13 +4871,16 @@ async function startExportJob(opts, bench) { // 0 = sesin süresi; aksi hâlde kare sayısı bundan (#523) durationSec, platform: process.platform, - lyricsLibrary: configFollowsLyrics(currentConfig) ? lyricsSnapshot() : [], + lyricsLibrary: configFollowsLyrics(jobCfg) ? lyricsSnapshot() : [], }); return { ok: true }; } -ipcMain.handle('export:start', (e, opts) => startExportJob(opts)); +ipcMain.handle('export:start', (e, opts) => { + if (exportQueue.running) return { ok: false, error: trUi('Dışa aktarma kuyruğu çalışıyor.', 'The export queue is running.') }; + return startExportJob(opts); +}); // Dışa aktarma bitene kadar bekler (yalnızca öz test kullanır) function awaitExportDone(timeoutMs) { @@ -4748,6 +4895,40 @@ function awaitExportDone(timeoutMs) { }); } +// Kuyruk (#526). Başlat beklemiyor: kuyruk arka planda koşuyor, panel +// durumu `export-queue` ile görüyor. +ipcMain.handle('export:queue-state', () => queueState()); +ipcMain.handle('export:queue-add', (e, items) => queueAdd(items)); +ipcMain.handle('export:queue-start', () => { + if (exportQueue.running) return { ok: false, error: 'running' }; + if (exportState) return { ok: false, error: trUi('Bir dışa aktarma sürüyor.', 'An export is already running.') }; + queueRun().catch(() => {}); + return { ok: true }; +}); +ipcMain.handle('export:queue-stop', () => queueStop()); +ipcMain.handle('export:queue-remove', (e, id) => queueRemove(String(id || ''))); +ipcMain.handle('export:queue-retry', (e, id) => queueRetry(String(id || ''))); +ipcMain.handle('export:queue-clear', () => queueClearFinished()); +ipcMain.handle('export:pick-audio-multi', async () => { + const r = await dialog.showOpenDialog(adminWin, { + title: trUi('Ses Dosyaları Seç', 'Choose Audio Files'), + properties: ['openFile', 'multiSelections'], + filters: [ + { name: trUi('Ses Dosyaları', 'Audio Files'), extensions: ['mp3', 'wav', 'flac', 'm4a', 'aac', 'ogg', 'opus'] }, + { name: trUi('Tümü', 'All Files'), extensions: ['*'] }, + ], + }); + return r.canceled ? [] : r.filePaths.slice(0, QUEUE_MAX); +}); +ipcMain.handle('export:pick-folder', async (e, defaultPath) => { + const r = await dialog.showOpenDialog(adminWin, { + title: trUi('Çıktı Klasörü Seç', 'Choose Output Folder'), + defaultPath: typeof defaultPath === 'string' && defaultPath ? defaultPath : undefined, + properties: ['openDirectory', 'createDirectory'], + }); + return r.canceled || !r.filePaths[0] ? null : r.filePaths[0]; +}); + ipcMain.handle('export:cancel', () => { if (exportState) exportState.cancel = true; return true; @@ -4905,6 +5086,9 @@ function finalizeExport(status, message, opts) { finalOut = partial; } + // Kuyruk işin sonucunu bekliyor (#526) + const waiters = exportDoneWaiters.splice(0); + for (const w of waiters) { try { w({ status, message: message || '', output: finalOut }); } catch { /* yok */ } } // Ölçüm kaydı SON durumu taşısın: ad verme ya da taşıma işi hataya çevirebiliyor if (prof && lastExportProfile) { lastExportProfile.status = status; lastExportProfile.message = message || ''; } notifyAdmin('export-done', { @@ -5135,6 +5319,29 @@ async function runExportBench(file) { Ölçüm boyunca panelin güncellemeleri öz testteki gibi bekletiliyor. */ smokeHoldStart(); const base = currentConfig; + /* Kuyruk denemesi (#526): işler kuyruğa eklenip kuyruk koşturuluyor. + İşin sahnesi eklendiği anın yapılandırması; sonradan değişmesi + sıradaki işleri etkilememeli (`changeAfterAdd`). */ + if (spec.queue) { + for (const q of spec.queue.items || []) { + currentConfig = JSON.parse(JSON.stringify(q.cfg || {})); + queueAdd([{ audioPath: q.audio || spec.audio, outputPath: path.join(spec.outDir, q.name), opts: q.opts || {} }]); + } + if (spec.queue.changeAfterAdd) currentConfig = JSON.parse(JSON.stringify(spec.queue.changeAfterAdd)); + if (spec.queue.stopAfterMs) setTimeout(() => queueStop(), spec.queue.stopAfterMs); + const t0 = Date.now(); + await queueRun(); + const state = queueState(); + console.log('[BENCH] ' + JSON.stringify({ queue: state, ms: Date.now() - t0 })); + currentConfig = base; + smokeHoldEnd(); + fs.writeFileSync(spec.result, JSON.stringify(state, null, 2)); + // app.exit will-quit'i atlıyor: bekleyen yarım dosya silmeleri burada + await new Promise((r) => setTimeout(r, 500)); + flushPartialsSync(3000); + app.exit(0); + return; + } for (const job of spec.jobs || []) { /* Profilin kayıtlı ayarları MİRAS ALINMIYOR: dışa aktarıcı iş yapılandırmasını varsayılanlarla birleştiriyor. Miras alınsaydı aynı @@ -5160,6 +5367,8 @@ async function runExportBench(file) { currentConfig = base; smokeHoldEnd(); fs.writeFileSync(spec.result, JSON.stringify(results, null, 2)); + await new Promise((r) => setTimeout(r, 500)); + flushPartialsSync(3000); app.exit(0); } @@ -7468,6 +7677,8 @@ function shutdownCleanup() { /* Süren dışa aktarım: ffmpeg durdurulur, geçici çıktı silinir (will-quit'teki flushPartialsSync bekler). Eskiden ffmpeg arkada kalabiliyor ve yarım dosya yerinde duruyordu. */ + // Kuyruk da durmalı: iptal edilen işin ardından sıradaki başlamasın + exportQueue.stopping = true; if (exportState) finalizeExport('cancelled'); quitting = true; // kapanış sırasında kaza koruması devreye girmemeli flushSettings(); // birleştirilmiş kayıt beklemede kalmasın diff --git a/src/main/preload-admin.js b/src/main/preload-admin.js index 86fd606..dba1757 100644 --- a/src/main/preload-admin.js +++ b/src/main/preload-admin.js @@ -133,6 +133,17 @@ contextBridge.exposeInMainWorld('api', { pickExportAudio: () => ipcRenderer.invoke('export:pick-audio'), pickExportOutput: (name, format) => ipcRenderer.invoke('export:pick-output', name, format), exportCapabilities: () => ipcRenderer.invoke('export:capabilities'), + // Dışa aktarma kuyruğu (#526) + exportQueueState: () => ipcRenderer.invoke('export:queue-state'), + exportQueueAdd: (items) => ipcRenderer.invoke('export:queue-add', items), + exportQueueStart: () => ipcRenderer.invoke('export:queue-start'), + exportQueueStop: () => ipcRenderer.invoke('export:queue-stop'), + exportQueueRemove: (id) => ipcRenderer.invoke('export:queue-remove', id), + exportQueueRetry: (id) => ipcRenderer.invoke('export:queue-retry', id), + exportQueueClear: () => ipcRenderer.invoke('export:queue-clear'), + onExportQueue: (cb) => ipcRenderer.on('export-queue', (e, d) => cb(d)), + pickExportAudioMulti: () => ipcRenderer.invoke('export:pick-audio-multi'), + pickExportFolder: (p) => ipcRenderer.invoke('export:pick-folder', p), startExport: (opts) => ipcRenderer.invoke('export:start', opts), cancelExport: () => ipcRenderer.invoke('export:cancel'), onExportProgress: (cb) => ipcRenderer.on('export-progress', (e, d) => cb(d)), diff --git a/src/shared/i18n.js b/src/shared/i18n.js index c5ccbd6..bb3421c 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2967,7 +2967,14 @@ 'Dikey — 1080×1920 (Reels / Shorts)': 'Vertical — 1080×1920 (Reels / Shorts)', /* Dışa aktarma profilleri (#522); export-formats.js etiketleriyle aynı */ 'İşaretçilerden bölümlere ayır': 'Split into chapters at the markers', - 'Hazır Profil': 'Preset Profile', 'Instagram kare — 1080×1080': 'Instagram square — 1080×1080', + 'Hazır Profil': 'Preset Profile', + /* Dışa aktarma kuyruğu (#526) */ + '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.', + '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', 'Ana kopya 4K — ProRes 422 HQ': 'Master 4K — ProRes 422 HQ', 'GIF döngü — 720p': 'GIF loop — 720p', 'Ses dosyasının süresi': 'Length of the audio file', 'Sabit süre': 'Fixed length', diff --git a/tests/audio-tags.test.js b/tests/audio-tags.test.js index dd18154..7af48e7 100644 --- a/tests/audio-tags.test.js +++ b/tests/audio-tags.test.js @@ -35,7 +35,7 @@ test('dışa aktarma dosyanın kapağını ve söz kuralını taşır', () => { assert.match(main, /function configFollowsLyrics/); assert.match(main, /if \(process\.platform !== 'win32' \|\| !cfg\) return false/); assert.match(main, /platform: process\.platform/); - assert.match(main, /lyricsLibrary: configFollowsLyrics\(currentConfig\) \? lyricsSnapshot\(\) : \[\]/); + assert.match(main, /lyricsLibrary: configFollowsLyrics\(jobCfg\) \? lyricsSnapshot\(\) : \[\]/); // Windows dışı zorlama platformAdjust(c) içinde: açılışta cfg ile, varsayılanlarda kopyayla assert.match(admin, /c\.text\.lyricsFollow = false/); assert.match(admin, /if \(platformAdjust\(cfg\)\) push\(true\);/); diff --git a/tests/export-formats.test.js b/tests/export-formats.test.js index 761bde7..3db01d8 100644 --- a/tests/export-formats.test.js +++ b/tests/export-formats.test.js @@ -228,8 +228,13 @@ test('I420 yalnız uygun boyutta; dönüştürücü kurulamazsa iş açıkça du açılamamalı: ayrı bir parametrede ve yalnız ölçüm sürücüsü veriyor. */ test('ölçüm seçenekleri MCP ve panelden açılamıyor', () => { const main = fs.readFileSync(path.join(__dirname, '..', 'src', 'main', 'main.js'), 'utf8'); - assert.match(main, /async function startExportJob\(opts, bench\)/); - assert.match(main, /ipcMain\.handle\('export:start', \(e, opts\) => startExportJob\(opts\)\);/); + assert.match(main, /async function startExportJob\(opts, bench, extra\)/); + // Panelin tek işi yalnız opts veriyor; kuyruk koşarken reddediliyor (#526) + const h = main.slice(main.indexOf("ipcMain.handle('export:start'"), main.indexOf("ipcMain.handle('export:start'") + 400); + assert.match(h, /return startExportJob\(opts\);/); + assert.match(h, /if \(exportQueue\.running\) return \{ ok: false/); + // Kuyruk işi yalnız sahne kopyasını veriyor, ölçüm parametresi null + assert.match(main, /startExportJob\(Object\.assign\(\{\}, item\.opts, \{ audioPath: item\.audioPath, outputPath: item\.outputPath \}\), null, \{ cfg: item\.cfg \}\)/); assert.match(main, /startExport: \(opts\) => startExportJob\(opts\),/); assert.ok(!/opts\.(profile|dumpRaw|_rgba)/.test(main), 'opts ölçüm seçeneği taşımıyor'); assert.match(main, /dump: bench\.dumpRaw \?/); diff --git a/tests/export-queue.test.js b/tests/export-queue.test.js new file mode 100644 index 0000000..17b12a2 --- /dev/null +++ b/tests/export-queue.test.js @@ -0,0 +1,81 @@ +'use strict'; +/* DIŞA AKTARMA KUYRUĞU (#526). + * + * Uçtan uca ölçüldü (uygulamada, --bench-export kuyruk kipi ve panelin + * kendi düğmeleriyle): + * - aynı adlı iki iş: ikincisi "q1 (2).mp4" + * - ses dosyası olmayan iş "Ses dosyası bulunamadı." ile hata, kuyruk sürdü + * - işler eklendikten SONRA sahne değişti: ilk iki işin kareleri birebir + * aynı (framemd5) — her iş eklendiği anın sahnesini taşıyor + * - Durdur: koşan iş iptal, kalanlar bekliyor, klasörde yarım dosya yok + * - panel: satırlar Bekliyor → Çalışıyor → Bitti; kuyruk bitince + * "Videoya Aktar" yeniden açılıyor + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); +const M = read('src/main/main.js'); +const fn = (name) => { + const i = M.indexOf('function ' + name + '('); + assert.ok(i > 0, name); + const end = /\r?\n\}\r?\n/.exec(M.slice(i)); + return M.slice(i, i + (end ? end.index : 4000)); +}; + +test('iş eklendiği anın sahnesini taşıyor', () => { + const add = fn('queueAdd'); + assert.match(add, /cfg: JSON\.parse\(JSON\.stringify\(currentConfig \|\| \{\}\)\),/); + assert.match(add, /outputPath: queueUniquePath\(r\.outputPath\),/); + assert.match(add, /badOutPath\(r\.outputPath, \[fmt\.ext\]\)/, 'yol kuralı tek işle aynı'); + assert.match(add, /if \(exportQueue\.items\.length >= QUEUE_MAX\) break;/); + assert.match(M, /startExportJob\(Object\.assign\(\{\}, item\.opts, \{ audioPath: item\.audioPath, outputPath: item\.outputPath \}\), null, \{ cfg: item\.cfg \}\)/); + // İş, verilen kopyayı kullanıyor; canlı yapılandırmayı değil + const job = M.slice(M.indexOf('async function startExportJob('), M.indexOf("ipcMain.handle('export:start'")); + assert.match(job, /const jobCfg = \(extra && extra\.cfg\) \|\| currentConfig;/); + assert.ok(!/currentConfig\./.test(job.slice(job.indexOf('const jobCfg'))), 'işin içinde currentConfig okunmuyor'); +}); + +test('hata kuyruğu durdurmuyor; başlamayan iş bekleyiciyi geri alıyor', () => { + const run = fn('queueRun'); + assert.match(run, /if \(!res \|\| !res\.ok\) \{[\s\S]*?exportDoneWaiters\.indexOf\(resolveDone\)[\s\S]*?item\.status = 'error';[\s\S]*?continue;/); + assert.match(run, /while \(exportState\) await/, 'elle başlatılmış iş bitene kadar bekliyor'); + assert.match(run, /finally \{\s*exportQueue\.running = false;/); +}); + +test('durdurma, kapanış ve tek iş', () => { + assert.match(fn('queueStop'), /exportQueue\.stopping = true;\s*if \(exportState\) exportState\.cancel = true;/); + assert.match(M, /function shutdownCleanup\(\) \{[\s\S]{0,600}exportQueue\.stopping = true;\s*if \(exportState\) finalizeExport\('cancelled'\);/); + const h = M.slice(M.indexOf("ipcMain.handle('export:start'"), M.indexOf("ipcMain.handle('export:start'") + 300); + assert.match(h, /if \(exportQueue\.running\) return \{ ok: false/); + assert.match(fn('queueRemove'), /it\.status !== 'running'/, 'koşan iş kaldırılamaz'); +}); + +test('aynı ad diskte ya da kuyrukta varsa numaralanıyor', () => { + const u = fn('queueUniquePath'); + assert.match(u, /fs\.existsSync\(q\) \|\| exportQueue\.items\.some\(\(it\) => it\.status !== 'done' && it\.outputPath === q\)/); + assert.match(u, /stem \+ ' \(' \+ n \+ '\)' \+ ext/); +}); + +test('finalizeExport bekleyenlere sonucu veriyor', () => { + const fin = M.slice(M.indexOf('function finalizeExport('), M.indexOf("notifyAdmin('export-done'", M.indexOf('function finalizeExport('))); + assert.match(fin, /const waiters = exportDoneWaiters\.splice\(0\);/); + assert.match(fin, /w\(\{ status, message: message \|\| '', output: finalOut \}\)/); +}); + +test('panel: düğmeler, satırlar, kuyruğun açtığı durum kapanıyor, çeviriler', () => { + const A = read('src/admin/admin.js'); + assert.match(A, /\{ type: 'exportqueue', tail: true \},/); + assert.match(A, /case 'exportqueue':\s*return exportQueueCtrl\(def\);/); + assert.match(A, /else if \(!exportQueueSt\.running && exportByQueue\) \{ exporting = false; exportByQueue = false; setExportUI\(false\); \}/); + for (const k of ['Kuyruğa Ekle', 'Birden Çok Ses Ekle', 'Kuyruğu Başlat', 'Kuyruğu Durdur', 'Bitenleri Temizle', + 'Bekliyor', 'Çalışıyor', 'Bitti', 'İptal edildi', 'Yeniden Dene', 'Kuyruk boş.']) { + assert.ok(read('src/shared/i18n.js').includes("'" + k + "':"), k); + } + const P = read('src/main/preload-admin.js'); + for (const k of ['exportQueueAdd', 'exportQueueStart', 'exportQueueStop', 'exportQueueRemove', 'exportQueueRetry', 'exportQueueClear', 'onExportQueue', 'pickExportAudioMulti', 'pickExportFolder']) { + assert.ok(P.includes(k + ':'), k); + } +}); diff --git a/tests/milkdrop-reduce-motion.test.js b/tests/milkdrop-reduce-motion.test.js index e3f6683..accde2c 100644 --- a/tests/milkdrop-reduce-motion.test.js +++ b/tests/milkdrop-reduce-motion.test.js @@ -107,7 +107,7 @@ test('dışa aktarım üreten makinenin ayarına bağlı değil; "Her zaman" ora test('dışa aktarımda "Her zaman" motora ulaşıyor: ayarın tamamı taşınıyor', () => { /* `milkdropControl` sahne anahtarı değil; dışa aktarıcı yalnız sahneyi alsaydı 'on' motora hiç varmaz, belgelerdeki söz boşa çıkardı. */ - assert.match(read('src/main/main.js'), /exportWin\.webContents\.send\('export:job', \{[\s\S]*?cfg: currentConfig \|\| \{\},/); + assert.match(read('src/main/main.js'), /exportWin\.webContents\.send\('export:job', \{[\s\S]*?cfg: jobCfg \|\| \{\},/); assert.match(read('src/exporter/exporter.js'), /cfg = window\.SV\.deepMerge\(window\.SV\.defaultConfig\(\), job\.cfg \|\| \{\}\);/); require('../src/visualizer/layers.js'); const cfg = SV.defaultConfig();