diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a9513..fbac21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,19 @@ GitHub. The full reasoning and the measurements behind each change are in [ROADM 31 s to 11 s, MilkDrop at 1080p60 from 31 s to 13 s, and MilkDrop at 4K from 60–67 s to 18–19 s. The conversion matches ffmpeg's to within one level on luma; the scene itself is drawn exactly as before. +- **Choose how long the export runs [523].** The length of the audio file (as before), a fixed + number of seconds, the length of the timeline, or N bars at a BPM (or at the timeline's tempo). + Video and audio come out exactly that long: shorter audio is padded with silence, and longer + audio is cut (a trimmed MP3 is still copied, not re-encoded). An empty timeline is refused with + a reason instead of rendering nothing. +- **Scene and BPM go into the file [528].** The save dialog suggests "Track - Scene - 128 BPM", + with characters Windows forbids and reserved names like CON removed. The file's comment carries + the scene name and BPM next to the title, artist and album. +- **A cancelled export leaves nothing behind [532].** The file is written under a `.partial` name + and moved to its real name only when ffmpeg finishes cleanly, so a cancel, an error or quitting + the app never leaves a half-written file at the chosen path. Quitting during an export used to + leave ffmpeg running; it is now stopped and its partial output removed. The status line shows + frames per second and the time left. - **The panel opens without waiting for the encoder probe [521].** GPU encoders are tested one at a time, and a saved "GPU" choice is no longer rewritten to "CPU" on a machine without one; the export runs on the CPU and the box shows it. diff --git a/README.md b/README.md index 31bc6de..f2c7c96 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-3123%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3132%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -933,7 +933,9 @@ the fixtures on the left take the colours on the left of the screen. 1080×1080, 24 to 60 fps. The GPU encoder is NVIDIA NVENC, AMD AMF or Intel Quick Sync, whichever passes a test encode on your machine. Colours are converted and tagged as BT.709, so players show what the screen showed. MP4 and WebM export about three times faster than in v3.1.5: a - 1080p60 song renders in about half its own length. + 1080p60 song renders in about half its own length. The length can be the audio file, a fixed + 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. - **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. @@ -1713,7 +1715,7 @@ npm test npm start -- --smoke ``` -**3123 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3132 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 8c64842..baed443 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-3123%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3132%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -932,7 +932,9 @@ ekranın solundaki renkleri alıyor. 24–60 fps. GPU kodlayıcısı NVIDIA NVENC, AMD AMF ya da Intel Quick Sync; hangisi bilgisayarınızda deneme kodlamasını geçerse. Renkler BT.709 olarak çevrilip etiketleniyor, oynatıcı ekranda görüneni gösteriyor. MP4 ve WebM dışa aktarımı v3.1.5'e göre yaklaşık üç kat hızlı: 1080p60 - bir şarkı kendi süresinin yarısında çıkıyor. + bir şarkı kendi süresinin yarısında çıkıyor. Süre ses dosyasından, sabit bir süreden, zaman + çizelgesinden ya da ölçü sayısından alınabiliyor; dosya parça, sahne ve BPM ile adlandırılıp + etiketleniyor, iptal edilen dışa aktarım yarım dosya bırakmıyor. - Çı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**. @@ -1716,7 +1718,7 @@ npm test npm start -- --smoke ``` -**3123 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3132 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 47659cb..d8796b1 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) and about 3× faster export (#531) done; profiles, queue, chapters and the rest 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) done; profiles, queue, chapters and 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 ``` -- **3123 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3132 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,35 @@ 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. +### Duration, audio, metadata and clean cancel (#523, #524, #528, #532) · done + +- **Duration.** The export lasts the audio file's length (the default), a fixed number of seconds, the timeline's length (latest clip, marker or loop end), or N bars. Bars use a typed BPM or, when it is 0, the timeline's tempo map. The main process measures the audio with ffmpeg and resolves the length before it starts. The renderer draws exactly `ceil(seconds × fps)` frames, and the frames past the end of the audio are drawn with silence. +- **Audio follows the video's length (#524).** If the audio is shorter, it is padded with `apad` and re-encoded, because a copied stream cannot be filtered. If it is longer, it is cut with `-t`, and an MP3 or AAC source is still copied. Each container keeps its codec: AAC or a copy in MP4, PCM s24 in MOV, Opus in WebM, and no audio for GIF, APNG or frame sequences. +- **Metadata (#528).** + - The save dialog suggests "Track - Scene - 128 BPM". + - Characters Windows forbids, reserved names (CON, NUL, COM1…) and trailing dots or spaces are removed, and the name is capped at 150 characters. + - The file's `comment` carries `Scene: … · BPM: …` next to the title, artist and album. MP4 and MOV do not write custom keys, and `comment` is read everywhere. + - Over MCP, an export without a scene name takes the active scene. +- **No partial file at the final path (#532).** + - **Rename on success.** Output goes to `name.partial.ext`, or `folder.partial` for a sequence, and is renamed only after ffmpeg exits with 0. If the rename fails, the job reports an error and points at the partial file instead of losing it. + - **Cancel or error.** The partial output is removed. A renderer crash still keeps what was written. + - **Quitting.** Quitting during an export now stops ffmpeg and removes the partial file. Before, ffmpeg could keep running after the app closed. + - **Status line.** It shows frames per second and the time left. The clock starts at the first frame, so decoding and scene setup do not inflate it. +- **Measured in the app** with 5 s of audio: + + | job | frames | video / audio | audio | + |---|---|---|---| + | 8 s fixed | 240 | 8.000 / 8.000 s | AAC; silence after 5 s (−inf dB) | + | 3 s fixed, WAV | 90 | 3.000 / 3.000 s | AAC | + | 3 s fixed, MP3 | 90 | 3.000 / 3.005 s | copied; packet boundary | + | 8 bars at 120 BPM | 480 | 16.000 / 16.000 s | comment "BPM: 120" | + | timeline, marker at 6 s | 180 | 6.000 / 6.000 s | | + | empty timeline | — | refused with a reason | | + | cancel after 2.5 s | — | no file, no `.partial` | | + | quit after 3 s | — | no file, no `.partial`, no ffmpeg left | | + + A 6 s PNG sequence over 5 s of audio wrote 180 frames into the final folder. + ### Per-application capture on Linux and macOS (#514, #517) · done, macOS unverified - **Linux** needs no new binary. It lists applications from `pactl list sink-inputs` (always under `LC_ALL=C`; JSON on pactl 16+, text before) and captures each matching stream with `parec --monitor-stream=` as 48 kHz stereo float32, the format the Windows helper delivers. A clock-driven mixer adds the streams and sends silence when nothing plays. diff --git a/src/admin/admin.js b/src/admin/admin.js index 4c51608..e706d6b 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -3385,6 +3385,30 @@ type: 'select', path: 'export.format', label: 'Biçim', rebuild: true, options: () => exportFormatOptions(), }, + { + type: 'select', path: 'export.duration.mode', label: 'Süre', rebuild: true, + options: [ + { value: 'audio', label: 'Ses dosyasının süresi' }, + { value: 'seconds', label: 'Sabit süre' }, + { value: 'timeline', label: 'Zaman çizelgesinin uzunluğu' }, + { value: 'bars', label: 'Ölçü sayısı (BPM\'den)' }, + ], + }, + { + type: 'slider', path: 'export.duration.seconds', label: 'Süre (saniye)', + min: 1, max: 1800, step: 1, fmt: (v) => fmtClock(v), + show: () => exportDuration().mode === 'seconds', + }, + { + type: 'slider', path: 'export.duration.bars', label: 'Ölçü', + min: 1, max: 512, step: 1, + show: () => exportDuration().mode === 'bars', + }, + { + type: 'slider', path: 'export.duration.bpm', label: 'BPM', + min: 0, max: 300, step: 1, fmt: (v) => (Math.round(v) > 0 ? String(Math.round(v)) : 'Zaman çizelgesi'), + show: () => exportDuration().mode === 'bars', + }, { type: 'select', path: 'export.resolution', label: 'Çözünürlük', options: [ @@ -3443,6 +3467,27 @@ ]; } + /* Süre kaynağı (#523); eski kayıtlarda alan yok. */ + function exportDuration() { + const e = cfg.export || (cfg.export = {}); + if (!e.duration || typeof e.duration !== 'object') e.duration = { mode: 'audio', seconds: 30, bars: 16, bpm: 0 }; + return e.duration; + } + // 75 -> "1:15", 3725 -> "1:02:05" + function fmtClock(sec) { + const t = Math.max(0, Math.round(Number(sec) || 0)); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = String(t % 60).padStart(2, '0'); + return h ? h + ':' + String(m).padStart(2, '0') + ':' + s : m + ':' + s; + } + /* Dosya adı ve içine yazılan meta veri için o anki sahne (#528). */ + function activeSceneName() { + const id = activeSceneId || cfg._activeSceneId; + const sc = id && Array.isArray(cfg.scenes) ? cfg.scenes.find((x) => x && x.id === id) : null; + return sc && sc.name ? String(sc.name) : ''; + } + /* Seçili biçimin ana süreçten gelen tanımı; liste henüz gelmediyse null. */ function exportFormat() { const id = (cfg.export && cfg.export.format) || 'mp4-h264'; @@ -5742,7 +5787,14 @@ const base = (exportAudioName || 'gorsellestirme').replace(/\.[^.]+$/, ''); const fmt = exportFormat(); const fmtId = fmt ? fmt.id : 'mp4-h264'; - const outputPath = await window.api.pickExportOutput(base + '.' + (fmt ? fmt.ext : 'mp4'), fmtId); + const dur = exportDuration(); + const sceneName = activeSceneName(); + /* Varsayılan ad: "Parça - Sahne - 128 BPM" (#528); kullanıcı değiştirebilir */ + const FX = window.SVExportFormats; + const suggested = FX + ? FX.outputName({ title: base, scene: sceneName, bpm: dur.mode === 'bars' ? dur.bpm : 0 }, fmt ? fmt.ext : 'mp4') + : base + '.' + (fmt ? fmt.ext : 'mp4'); + const outputPath = await window.api.pickExportOutput(suggested, fmtId); if (!outputPath) return; push(true); // en güncel görsel ayarları ana sürece gönder @@ -5751,6 +5803,8 @@ audioPath: exportAudioPath, outputPath, format: fmtId, + duration: { mode: dur.mode, seconds: dur.seconds, bars: dur.bars, bpm: dur.bpm }, + sceneName, resolution: cfg.export.resolution, fps: cfg.export.fps, quality: cfg.export.quality, @@ -6287,7 +6341,7 @@ let exportEnc = ''; window.api.onExportProgress((d) => { if (d.phase === 'start') { - exportEnc = d.encoder === 'gpu' ? 'GPU/NVENC' : 'CPU/libx264'; + exportEnc = d.encoderLabel || (d.encoder === 'gpu' ? 'GPU' : 'CPU'); return; } if (d.phase === 'encode') { @@ -6301,7 +6355,12 @@ exportPct = pct; const fill = $('exportProgressFill'); if (fill) fill.style.width = pct + '%'; - setExportStatus('Render ediliyor [' + exportEnc + ']… %' + pct + ' (' + d.done + ' / ' + d.total + ' kare)'); + /* Hız ve kalan süre (#532) ' · ' ile ekleniyor: çevirici satırı bu + ayırıcıdan bölüp parçaları ayrı çeviriyor. */ + let line = 'Render ediliyor [' + exportEnc + ']… %' + pct + ' (' + d.done + ' / ' + d.total + ' kare)'; + if (d.fps > 0) line += ' · ' + (d.fps >= 10 ? Math.round(d.fps) : d.fps) + ' fps'; + if (d.eta != null) line += ' · kalan ' + fmtClock(d.eta); + setExportStatus(line); }); window.api.onExportDone((d) => { exporting = false; diff --git a/src/exporter/exporter.js b/src/exporter/exporter.js index ee011da..9f388d7 100644 --- a/src/exporter/exporter.js +++ b/src/exporter/exporter.js @@ -423,7 +423,12 @@ if (prof) { prof.decodeMs = performance.now() - tp; tp = performance.now(); } await setup(job); if (prof) prof.setupMs = performance.now() - tp; - totalFrames = Math.max(1, Math.ceil(duration * fps)); + /* Süre ana süreçten gelebilir (#523): sabit saniye, zaman çizelgesi ya + da N ölçü. Ses bittikten sonraki kareler sessizlikle çiziliyor + (analyzeAt dosya dışını sıfır okuyor); ffmpeg sesi de aynı süreye + dolduruyor ya da kesiyor. */ + const renderSec = Number(job.durationSec) > 0 ? Number(job.durationSec) : duration; + totalFrames = Math.max(1, Math.ceil(renderSec * fps)); window.exp.ready(totalFrames); // Boru hattı (pipeline): render, IPC ve ffmpeg kodlaması ÜST ÜSTE çalışsın. diff --git a/src/main/main.js b/src/main/main.js index e4ad823..ef9381a 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -4290,6 +4290,30 @@ function removePartialDir(dir, proc) { if (proc && proc.exitCode == null && proc.signalCode == null) proc.once('close', () => setTimeout(drop, 50)); else setTimeout(drop, 50); } +/* Geçici çıktıyı asıl adına taşır. Dosyada hedef varsa üstüne yazılır + (kaydetme penceresi bunu zaten sordu); kare dizisinde klasör adı + doluysa yanına numaralı yenisi seçilir. Windows tutamacı hemen + bırakmayabildiği için birkaç kez denenir. */ +function promotePartial(partial, workDir, finalPath) { + let dest = finalPath; + if (workDir) { + const base = finalPath; + for (let n = 2; fs.existsSync(dest) && n < 1000; n++) dest = base + ' (' + n + ')'; + } + const src = workDir || partial; + let last = null; + const nap = new Int32Array(new SharedArrayBuffer(4)); + for (let i = 0; i < 10; i++) { + try { + fs.renameSync(src, dest); + return { ok: true, path: dest }; + } catch (err) { + last = err; + Atomics.wait(nap, 0, 0, 100); + } + } + return { ok: false, error: (last && last.message) || 'rename' }; +} function flushPartialsSync(budgetMs) { const end = Date.now() + budgetMs; const nap = new Int32Array(new SharedArrayBuffer(4)); @@ -4299,6 +4323,21 @@ function flushPartialsSync(budgetMs) { } } +/* Ses dosyasının süresi, saniye (ffmpeg dökümündeki "Duration:"). Ölçülemezse 0. */ +function audioDurationSec(ff, file) { + return new Promise((resolve) => { + let err = ''; + let p; + try { p = spawn(ff, ['-hide_banner', '-i', file], { windowsHide: true }); } catch { return resolve(0); } + p.stderr.on('data', (d) => { err += d.toString(); }); + p.on('error', () => resolve(0)); + p.on('close', () => { + const m = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(err); + resolve(m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : 0); + }); + }); +} + // Paketlenmiş ffmpeg (ffmpeg-static); yoksa sistem PATH'indeki "ffmpeg". function resolveFfmpeg() { try { @@ -4454,18 +4493,21 @@ async function startExportJob(opts, bench) { (ad_000001.png …). Klasör zaten varsa yanına numaralı yenisi açılır; başka bir işin karelerinin üstüne yazılmaz. */ let target = outputPath; + /* Yarım dosya asıl yolda hiç olmuyor (#532): çıktı önce `.partial` + adıyla yazılıyor, ffmpeg başarıyla kapanınca asıl adına taşınıyor. + Kare dizisinde aynısı klasör için. */ let seqDir = null; + let workDir = null; if (fmt.kind === 'sequence') { const stem = path.basename(outputPath, path.extname(outputPath)); const parent = path.dirname(outputPath); seqDir = path.join(parent, stem); for (let n = 2; fs.existsSync(seqDir) && n < 1000; n++) seqDir = path.join(parent, stem + ' (' + n + ')'); - try { - fs.mkdirSync(seqDir, { recursive: true }); - } catch (err) { - return { ok: false, error: trUi('Klasör açılamadı: ', 'Could not create the folder: ') + err.message }; - } - target = exportFormats.sequencePattern(seqDir, stem, fmt.ext); + workDir = seqDir + '.partial'; + for (let n = 2; fs.existsSync(workDir) && n < 1000; n++) workDir = seqDir + '.partial' + n; + target = exportFormats.sequencePattern(workDir, stem, fmt.ext); + } else { + target = exportFormats.partialPath(outputPath); } const ff = resolveFfmpeg(); @@ -4487,6 +4529,44 @@ async function startExportJob(opts, bench) { if (read) track = read; } catch { /* etiket yoksa dosya adı yeter */ } + /* Süre (#523): sesin kendisi, sabit saniye, zaman çizelgesinin + uzunluğu ya da N ölçü. Ses süresi ffmpeg'den ölçülüyor; video ve ses + tam bu süre kadar yazılıyor (ses kısaysa sessizlikle doluyor). */ + 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) || {}; + let tempoMap = null; + try { + tempoMap = Number(durSpec.bpm) > 0 + ? TL.makeTempoMap([{ t: 0, bpm: Number(durSpec.bpm), beatsPerBar: Number(durSpec.beatsPerBar) > 0 ? Number(durSpec.beatsPerBar) : 4 }]) + : TL.makeTimeline(tlCfg).tempo; + } catch { tempoMap = null; } + const dur = exportFormats.resolveDuration(durSpec, { + audioSec, + timelineSec: (() => { try { return TL.timelineLength(TL.makeTimeline(tlCfg)); } catch { return 0; } })(), + barsToSeconds: tempoMap ? (n) => TL.barsToSeconds(tempoMap, n + 1, 1) : null, + }); + if (!dur.ok) { + const msg = { + TIMELINE_EMPTY: trUi('Zaman çizelgesi boş; süre ondan alınamaz.', 'The timeline is empty, so it cannot set the duration.'), + BAD_BARS: trUi('Ölçü sayısı geçersiz.', 'The number of bars is not valid.'), + NO_DURATION: trUi('Süre belirlenemedi.', 'The duration could not be determined.'), + }[dur.code] || dur.code; + return { ok: false, error: msg }; + } + /* "Sesin süresi" seçiliyken eski kural geçerli (-shortest): ffmpeg'in + ölçtüğü süreyle çözülen süre arasındaki küçük fark sesi kesmesin. */ + const durationSec = dur.mode === 'audio' ? 0 : dur.seconds; + /* 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 (sc && typeof sc.name === 'string') sceneName = sc.name.slice(0, 120); + } + const bpmMeta = Number(durSpec.bpm) > 0 ? Number(durSpec.bpm) + : (tlCfg.enabled && tempoMap && tempoMap[0] ? tempoMap[0].bpm : 0); + /* Kare GPU'da I420'ye çevrilsin mi (#531)? 4:2:0 biçimlerde ve boyut uygunsa evet. Ölçüm sürücüsü karşılaştırma için RGBA yolunu zorlayabiliyor (`bench.rgba`). */ @@ -4495,17 +4575,29 @@ async function startExportJob(opts, bench) { const args = exportFormats.buildArgs({ fmt, encoder: encoderId, w, h, fps, quality, speed, inputPix: pix, threads: Math.max(1, Math.min(8, require('os').cpus().length)), - audioPath, audioExt: path.extname(audioPath), output: target, - meta: { title: track.title, artist: track.artist, album: track.album }, + audioPath, audioExt: path.extname(audioPath), audioSec, durationSec, output: target, + meta: { title: track.title, artist: track.artist, album: track.album, scene: sceneName, bpm: bpmMeta }, }); + if (workDir) { + try { + fs.mkdirSync(workDir, { recursive: true }); + } catch (err) { + return { ok: false, error: trUi('Klasör açılamadı: ', 'Could not create the folder: ') + err.message }; + } + } + try { ffmpegProc = spawn(ff, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] }); } catch (err) { return { ok: false, error: 'ffmpeg başlatılamadı: ' + err.message }; } - exportState = { outputPath: seqDir || outputPath, seqDir, format: fmt.id, encoderId, ffErr: '', encoder, finishAt: 0, cancel: false }; + exportState = { + outputPath: seqDir || outputPath, seqDir, workDir, partial: workDir || target, + format: fmt.id, encoderId, ffErr: '', encoder, finishAt: 0, cancel: false, + startedAt: 0, total: 0, + }; /* Ölçüm (#531): ana süreç tarafındaki yazma/bekleme süreleri ve kare özetleri. Özet, hızlandırmanın görüntüyü değiştirmediğini kanıtlamak için: aynı iş aynı kareleri vermeli. */ @@ -4513,7 +4605,10 @@ async function startExportJob(opts, bench) { exportState.prof = { t0: Date.now(), copyMs: 0, writeMs: 0, drainMs: 0, frames: 0, digest: require('crypto').createHash('sha1'), dump: bench.dumpRaw ? fs.createWriteStream(bench.dumpRaw) : null, pix }; } - notifyAdmin('export-progress', { phase: 'start', encoder }); + notifyAdmin('export-progress', { + phase: 'start', encoder, + encoderLabel: exportFormats.ENCODERS[encoderId] ? exportFormats.ENCODERS[encoderId].label : '', + }); ffmpegProc.stderr.on('data', (d) => { if (!exportState) return; exportState.ffErr += d.toString(); @@ -4580,6 +4675,8 @@ async function startExportJob(opts, bench) { arkaplan boyanmaz, arkaplan efektinin koyu yerleri saydamlaşır. */ alpha: !!fmt.alpha, pix, + // 0 = sesin süresi; aksi hâlde kare sayısı bundan (#523) + durationSec, platform: process.platform, lyricsLibrary: configFollowsLyrics(currentConfig) ? lyricsSnapshot() : [], }); @@ -4662,11 +4759,19 @@ ipcMain.handle('export:frame', async (e, data) => { return { cancel: !!(exportState && exportState.cancel) }; }); +/* İlerleme, saniyedeki kare ve kalan süre (#532). Saat ilk kareden + başlıyor: ses çözme ve sahne kurulumu kalan süreyi şişirmesin. */ ipcMain.on('export:ready', (e, total) => { + if (exportState) { exportState.startedAt = Date.now(); exportState.total = Number(total) || 0; } notifyAdmin('export-progress', { phase: 'render', done: 0, total }); }); ipcMain.on('export:progress', (e, p) => { - notifyAdmin('export-progress', { phase: 'render', done: p.done, total: p.total }); + const st = exportState && exportState.startedAt + ? exportFormats.progressStats(p.done, p.total, Date.now() - exportState.startedAt) : null; + notifyAdmin('export-progress', { + phase: 'render', done: p.done, total: p.total, + fps: st ? Math.round(st.fps * 10) / 10 : 0, eta: st ? st.eta : null, + }); }); function endExportStdin() { const proc = ffmpegProc; @@ -4689,6 +4794,8 @@ function finalizeExport(status, message, opts) { if (!exportState) return; // tek sefer const out = exportState.outputPath; const seqDir = exportState.seqDir; + const workDir = exportState.workDir; + const partial = exportState.partial; const encoder = exportState.encoder; const encoderId = exportState.encoderId; const prof = exportState.prof; @@ -4721,15 +4828,30 @@ function finalizeExport(status, message, opts) { } exportWin = null; - // Yarım kalan dosyayı temizle (iptal/hata). Çöken görüntü süreci - // dosyayı yazmış olabilir; o kopya yerinde kalır, süreç durur. - if (status !== 'done' && !(opts && opts.keep) && out) { - if (seqDir) removePartialDir(seqDir, proc); - else removePartial(out, proc); + /* Başarıda geçici çıktı asıl adına taşınıyor; taşınamazsa iş HATA + sayılıyor ve geçici dosya yerinde kalıyor (kaybolmasın). İptal ve + hatada geçici çıktı siliniyor; asıl yolda hiçbir zaman yarım dosya + olmuyor. */ + let finalOut = out; + if (status === 'done' && partial) { + const moved = promotePartial(partial, seqDir ? workDir : null, seqDir || out); + if (moved.ok) { + finalOut = moved.path; + } else { + status = 'error'; + finalOut = partial; + message = trUi('Dosya asıl adına taşınamadı: ', 'Could not move the file to its final name: ') + moved.error + ' (' + partial + ')'; + } + } else if (status !== 'done' && partial && !(opts && opts.keep)) { + if (workDir) removePartialDir(workDir, proc); + else removePartial(partial, proc); + } else if (opts && opts.keep) { + // Çöken görüntü süreci: yazılmış kısım saklanıyor, yeri söyleniyor + finalOut = partial; } notifyAdmin('export-done', { - status, output: out, message: message || '', encoder, + status, output: finalOut, message: message || '', encoder, encoderLabel: encoderId && exportFormats.ENCODERS[encoderId] ? exportFormats.ENCODERS[encoderId].label : '', }); } @@ -4964,8 +5086,11 @@ async function runExportBench(file) { const t0 = Date.now(); const res = await startExportJob(Object.assign({}, job.opts, { audioPath: job.audio || spec.audio, outputPath: out }), { rgba: !!job.rgba, dumpRaw: job.dumpRaw || null }); + /* İptal ve kapanış denemesi (#532): asıl yolda yarım dosya kalmamalı. */ + if (res && res.ok && job.cancelAfterMs) setTimeout(() => { if (exportState) exportState.cancel = true; }, job.cancelAfterMs); + if (res && res.ok && job.quitAfterMs) setTimeout(() => app.quit(), job.quitAfterMs); const done = res && res.ok ? await awaitExportDone(30 * 60 * 1000) : false; - const row = { name: job.name, ok: !!(res && res.ok) && done, error: res && res.error, ms: Date.now() - t0, + const row = { name: job.name, ok: !!(res && res.ok) && done, error: res && res.error, ms: Date.now() - t0, out, size: fs.existsSync(out) ? fs.statSync(out).size : 0, profile: lastExportProfile }; console.log('[BENCH] ' + JSON.stringify(row)); results.push(row); @@ -7277,6 +7402,10 @@ async function runSmoke() { her iki dalda da eksikti. textureShare'inki görünür bir hataya yol açıyordu — Spout göndericisi uygulama kapandıktan sonra da kayıtlı kalıyordu. */ 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. */ + if (exportState) finalizeExport('cancelled'); quitting = true; // kapanış sırasında kaza koruması devreye girmemeli flushSettings(); // birleştirilmiş kayıt beklemede kalmasın streamServer.stop().catch(() => {}); diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 570b57a..e386b0f 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -1173,6 +1173,9 @@ // her kare offline ve birebir render edilir, ses kaynaktan kopyalanır. export: { format: 'mp4-h264', // export-formats.js FORMATS kimliği (#521) + /* Süre kaynağı (#523): ses dosyası, sabit saniye, zaman çizelgesinin + uzunluğu ya da N ölçü (bpm 0 = zaman çizelgesinin temposu). */ + duration: { mode: 'audio', seconds: 30, bars: 16, bpm: 0 }, resolution: '1080p', // export-formats.js RESOLUTIONS anahtarı fps: 60, // 24 | 25 | 30 | 50 | 60 quality: 'visually-lossless', // 'visually-lossless' | 'high' | 'balanced' diff --git a/src/shared/export-formats.js b/src/shared/export-formats.js index 861845f..051f250 100644 --- a/src/shared/export-formats.js +++ b/src/shared/export-formats.js @@ -223,12 +223,14 @@ return !!v && (v.vf === yuvFilter('yuv420p') || v.vf === yuvFilter('nv12')); } - /* Ses kuralı, kaba göre. Kaynak dosya mp4'e uygunsa kopyalanıyor. */ - function audioArgs(fmt, audioExt) { + /* Ses kuralı, kaba göre. Kaynak dosya mp4'e uygunsa kopyalanıyor; ama + video sesten uzunsa ses sessizlikle doldurulacak (apad) ve kopyalanan + akışa süzgeç uygulanamıyor, o zaman yeniden kodlanıyor. */ + function audioArgs(fmt, audioExt, pad) { if (!fmt.audio) return null; const ext = String(audioExt || '').toLowerCase().replace(/^\./, ''); if (fmt.audio === 'mp4') { - return ['mp3', 'm4a', 'aac'].indexOf(ext) >= 0 ? ['-c:a', 'copy'] : ['-c:a', 'aac', '-b:a', '320k']; + return !pad && ['mp3', 'm4a', 'aac'].indexOf(ext) >= 0 ? ['-c:a', 'copy'] : ['-c:a', 'aac', '-b:a', '320k']; } if (fmt.audio === 'pcm') return ['-c:a', 'pcm_s24le']; if (fmt.audio === 'opus') return ['-c:a', 'libopus', '-b:a', '256k']; @@ -288,7 +290,12 @@ '-thread_queue_size', '1024', // ham kare girişi için geniş kuyruk '-i', 'pipe:0', ); - const aArgs = o.audioPath ? audioArgs(fmt, o.audioExt) : null; + /* Süre (#523): verilmişse video ve ses tam o kadar. Ses kısaysa + sessizlikle dolar, uzunsa kesilir; verilmemişse eski kural (-shortest, + sesin süresi). */ + const dur = Number(o.durationSec) > 0 ? Number(o.durationSec) : 0; + const pad = dur > 0 && Number(o.audioSec) > 0 && dur > Number(o.audioSec) + 0.001; + const aArgs = o.audioPath ? audioArgs(fmt, o.audioExt, pad) : null; if (aArgs) args.push('-i', o.audioPath); if (v.complex) { args.push('-filter_complex', '[0:v]' + v.complex + '[v]', '-map', '[v]'); @@ -299,16 +306,27 @@ if (aArgs) args.push('-map', '1:a:0'); args.push.apply(args, v.args); if (v.tags) args.push.apply(args, BT709_TAGS); - if (aArgs) args.push.apply(args, aArgs); - else args.push('-an'); + if (aArgs) { + if (pad) args.push('-af', 'apad'); + args.push.apply(args, aArgs); + } else { + args.push('-an'); + } const meta = o.meta || {}; if (fmt.kind !== 'sequence') { + /* Sahne adı ve BPM açıklama alanına (#528): mp4 ve mov yazıcısı özel + anahtar yazmıyor, `comment` her kapta okunuyor. */ + const extra = []; + if (meta.scene) extra.push('Scene: ' + meta.scene); + if (Number(meta.bpm) > 0) extra.push('BPM: ' + Math.round(Number(meta.bpm) * 100) / 100); + const fields = Object.assign({}, meta, { comment: [meta.comment].concat(extra).filter(Boolean).join(' · ') }); for (const k of ['title', 'artist', 'album', 'comment']) { - if (meta[k]) args.push('-metadata', k + '=' + String(meta[k]).replace(/[\r\n]+/g, ' ').slice(0, 512)); + if (fields[k]) args.push('-metadata', k + '=' + String(fields[k]).replace(/[\r\n]+/g, ' ').slice(0, 512)); } } if (fmt.ext === 'mp4' || fmt.ext === 'mov') args.push('-movflags', '+faststart'); - if (aArgs) args.push('-shortest'); + if (dur > 0) args.push('-t', dur.toFixed(6)); + else if (aArgs) args.push('-shortest'); if (fmt.kind === 'sequence') args.push('-f', 'image2', '-start_number', '1'); if (fmt.id === 'apng') args.push('-f', 'apng'); args.push(o.output); @@ -332,10 +350,84 @@ return args; } + // ------------------------------------------------------------ süre (#523) + + const DURATION_MODES = ['audio', 'seconds', 'timeline', 'bars']; + const MAX_SECONDS = 4 * 3600; + + /* Dışa aktarımın süresi, saniye. spec: { mode, seconds, bars, bpm }. + ctx: { audioSec, timelineSec, barsToSeconds(n) } — çağıran ölçer. + Dönen: { ok, mode, seconds } ya da { ok: false, code }. */ + function resolveDuration(spec, ctx) { + const sp = spec || {}; + const c = ctx || {}; + const mode = DURATION_MODES.indexOf(sp.mode) >= 0 ? sp.mode : 'audio'; + let sec = 0; + if (mode === 'audio') sec = Number(c.audioSec) || 0; + else if (mode === 'seconds') sec = Number(sp.seconds) || 0; + else if (mode === 'timeline') { + sec = Number(c.timelineSec) || 0; + if (!(sec > 0)) return { ok: false, code: 'TIMELINE_EMPTY' }; + } else if (mode === 'bars') { + const n = Math.round(Number(sp.bars) || 0); + if (!(n > 0) || typeof c.barsToSeconds !== 'function') return { ok: false, code: 'BAD_BARS' }; + sec = Number(c.barsToSeconds(n)) || 0; + } + if (!(sec > 0)) return { ok: false, code: 'NO_DURATION' }; + return { ok: true, mode, seconds: Math.min(sec, MAX_SECONDS) }; + } + + // -------------------------------------------------- dosya adı (#528) + + /* Windows'un ve öteki sistemlerin yasak karakterleri, ayrılmış adlar + (CON, NUL, COM1…) ve sondaki nokta/boşluk. Boş kalırsa yedek ad. */ + const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i; + function sanitizeFileName(name, fallback) { + let s = String(name == null ? '' : name) + .replace(/[\u0000-\u001f<>:"/\\|?*]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/[. ]+$/, ''); + if (s.length > 150) s = s.slice(0, 150).trim(); + if (!s || RESERVED.test(s)) s = fallback || 'export'; + return s; + } + + /* Varsayılan dosya adı: "Parça - Sahne - 128 BPM". Boş parçalar atlanır. */ + function outputName(meta, ext) { + const m = meta || {}; + const parts = [m.title, m.scene, Number(m.bpm) > 0 ? Math.round(Number(m.bpm)) + ' BPM' : ''] + .map((x) => String(x || '').trim()).filter(Boolean); + return sanitizeFileName(parts.join(' - '), 'export') + (ext ? '.' + ext : ''); + } + + // -------------------------------------------- yarım dosya yok (#532) + + /* Dosya önce bu adla yazılıyor, iş başarıyla bitince asıl adına + taşınıyor: iptal, hata ya da kapanışta asıl yolda yarım bir dosya + hiç olmuyor. Uzantı sonda kalıyor, ffmpeg kabı ondan seçiyor. */ + function partialPath(file) { + const s = String(file); + const dot = s.lastIndexOf('.'); + const sep = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); + return dot > sep ? s.slice(0, dot) + '.partial' + s.slice(dot) : s + '.partial'; + } + + /* Kalan süre: bitmiş kare oranından. İlk saniyede tahmin yok. */ + function progressStats(done, total, elapsedMs) { + const d = Math.max(0, Number(done) || 0); + const t = Math.max(1, Number(total) || 1); + const ms = Math.max(0, Number(elapsedMs) || 0); + const fps = ms > 0 ? d / (ms / 1000) : 0; + const eta = ms >= 1000 && fps > 0 ? Math.max(0, (t - d) / fps) : null; + return { pct: Math.min(100, (d / t) * 100), fps, eta }; + } + const api = { RESOLUTIONS, FPS, QUALITIES, SPEEDS, ENCODERS, FORMATS, DEFAULT_FORMAT, format, extensions, encodersFor, pickEncoder, available, videoArgs, audioArgs, buildArgs, probeArgs, sequencePattern, takesYuv420, + DURATION_MODES, MAX_SECONDS, resolveDuration, sanitizeFileName, outputName, partialPath, progressStats, }; if (typeof module !== 'undefined' && module.exports) module.exports = api; diff --git a/src/shared/i18n.js b/src/shared/i18n.js index b406acf..fc617d9 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2965,6 +2965,9 @@ 'Video Dışa Aktar (MP3 → Video)': 'Video Export (MP3 → Video)', /* Dışa aktarma biçimleri (#521); export-formats.js etiketleriyle aynı */ 'Dikey — 1080×1920 (Reels / Shorts)': 'Vertical — 1080×1920 (Reels / Shorts)', + 'Ses dosyasının süresi': 'Length of the audio file', 'Sabit süre': 'Fixed length', + 'Zaman çizelgesinin uzunluğu': 'Length of the timeline', 'Ölçü sayısı (BPM\'den)': 'Number of bars (from the BPM)', + 'Süre (saniye)': 'Duration (seconds)', 'Zaman çizelgesi': 'Timeline', 'Kare — 1080×1080': 'Square — 1080×1080', '24 FPS (sinema)': '24 FPS (cinema)', '25 FPS (PAL)': '25 FPS (PAL)', @@ -3433,6 +3436,8 @@ .replace(/^Yakalanıyor: (.+)$/g, (m, dev) => `Capturing: ${translate(dev)}`) .replace(/^Kodlanıyor \((.+)\)… kareler bitti, video yazılıyor\.$/g, 'Encoding ($1)… frames finished, writing video.') .replace(/^Render ediliyor \[(.+)\]… %(\d+) \((\d+) \/ (\d+) kare\)$/g, 'Rendering [$1]… $2% ($3 / $4 frames)') + // Dışa aktarımın kalan süresi (#532): "kalan 1:05" + .replace(/^kalan (\d+(?::\d\d){1,2})$/, '$1 left') .replace(/^Tamamlandı \((.+)\) → (.+)$/g, 'Completed ($1) → $2') .replace(/^Tamamlandı \((.+)\)$/g, 'Completed ($1)') .replace(/^Şablonum (\d+)$/g, 'My Template $1') diff --git a/src/shared/mcp.js b/src/shared/mcp.js index e13bd18..3b71bd6 100644 --- a/src/shared/mcp.js +++ b/src/shared/mcp.js @@ -1080,6 +1080,8 @@ format: sp('string', 'Container and codec. Default mp4-h264. Alpha: mov-prores4444, webm-vp9-alpha, apng, png-seq, exr-seq.', { enum: EXPORT_FORMAT_IDS }), resolution: sp('string', 'Video size. Default 1080p.', { enum: EXPORT_RESOLUTIONS }), fps: sp('integer', 'Frame rate. Default 60. GIF and APNG cap at 30.', { enum: EXPORT_FPS }), + duration: sp('object', 'How long to render. {mode: audio (default, the file length) | seconds | timeline | bars, seconds, bars, bpm (0 = timeline tempo)}. Audio shorter than the video is padded with silence; longer audio is cut.'), + sceneName: sp('string', 'Scene name for the file metadata. Default: the active scene.'), encoder: sp('string', 'Encoder. Default cpu.', { enum: ['cpu', 'gpu'] }), quality: sp('string', 'Quality.', { enum: ['visually-lossless', 'high', 'balanced'] }), speed: sp('string', 'Encoder speed. Default balanced.', { enum: ['fast', 'balanced', 'quality'] }), diff --git a/tests/export-job.test.js b/tests/export-job.test.js new file mode 100644 index 0000000..1807d61 --- /dev/null +++ b/tests/export-job.test.js @@ -0,0 +1,118 @@ +'use strict'; +/* DIŞA AKTARMA İŞİ: süre, ses, meta veri, yarım dosya (#523, #524, #528, #532). + * + * Uçtan uca ölçüldü (uygulamada, 5 sn ses): + * 8 sn sabit 240 kare, video 8,000 / ses 8,000 (AAC, 5. saniyeden sonrası -inf dB) + * 3 sn, mp3 90 kare, ses KOPYALANDI, 3,005 (paket sınırı) + * 8 ölçü @120 480 kare, 16,000 / 16,000, açıklamada "BPM: 120" + * zaman çizelgesi 6 sn işaretle 180 kare + * iptal ve işin ortasında kapanış: asıl yolda da .partial'da da dosya yok, + * arkada ffmpeg kalmıyor + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const F = require('../src/shared/export-formats.js'); + +const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8'); +const after = (a, flag) => a[a.indexOf(flag) + 1]; +const args = (extra) => F.buildArgs(Object.assign({ + fmt: F.format('mp4-h264'), encoder: 'libx264', w: 64, h: 64, fps: 30, quality: 'high', speed: 'fast', + audioPath: 'C:/a/s.mp3', audioExt: '.mp3', audioSec: 10, output: 'C:/o/x.partial.mp4', +}, extra || {})); + +test('süre kaynakları', () => { + const ctx = { audioSec: 200, timelineSec: 95.5, barsToSeconds: (n) => n * 2 }; + assert.deepStrictEqual(F.resolveDuration({ mode: 'audio' }, ctx), { ok: true, mode: 'audio', seconds: 200 }); + assert.strictEqual(F.resolveDuration({ mode: 'seconds', seconds: 42 }, ctx).seconds, 42); + assert.strictEqual(F.resolveDuration({ mode: 'timeline' }, ctx).seconds, 95.5); + assert.strictEqual(F.resolveDuration({ mode: 'bars', bars: 16 }, ctx).seconds, 32); + assert.strictEqual(F.resolveDuration({ mode: 'bilinmeyen' }, ctx).mode, 'audio', 'bilinmeyen kip ses'); + assert.strictEqual(F.resolveDuration({ mode: 'seconds', seconds: 1e9 }, ctx).seconds, F.MAX_SECONDS); + assert.deepStrictEqual(F.resolveDuration({ mode: 'timeline' }, { timelineSec: 0 }), { ok: false, code: 'TIMELINE_EMPTY' }); + assert.strictEqual(F.resolveDuration({ mode: 'bars', bars: 0 }, ctx).code, 'BAD_BARS'); + assert.strictEqual(F.resolveDuration({ mode: 'seconds', seconds: 0 }, ctx).code, 'NO_DURATION'); + assert.strictEqual(F.resolveDuration({ mode: 'audio' }, { audioSec: 0 }).code, 'NO_DURATION'); +}); + +test('ses: süre verilmezse -shortest, kısaysa apad ve kodlama, uzunsa kopya ve kesme', () => { + const plain = args(); + assert.ok(plain.includes('-shortest') && !plain.includes('-t')); + assert.strictEqual(after(plain, '-c:a'), 'copy'); + + const longer = args({ durationSec: 20 }); + assert.strictEqual(after(longer, '-af'), 'apad'); + assert.strictEqual(after(longer, '-c:a'), 'aac', 'doldurulan ses kopyalanamaz'); + assert.strictEqual(after(longer, '-t'), '20.000000'); + assert.ok(!longer.includes('-shortest')); + + const shorter = args({ durationSec: 3 }); + assert.ok(!shorter.includes('-af')); + assert.strictEqual(after(shorter, '-c:a'), 'copy'); + assert.strictEqual(after(shorter, '-t'), '3.000000'); + assert.ok(shorter.indexOf('-t') < shorter.length - 1, '-t çıktıdan önce'); +}); + +test('meta veri: sahne ve BPM açıklamada, satır sonu yok', () => { + const a = args({ meta: { title: 'Parça', scene: 'Kulüp\nGece', bpm: 127.996 } }); + assert.ok(a.includes('title=Parça')); + assert.ok(a.includes('comment=Scene: Kulüp Gece · BPM: 128'), a.join(' ')); + assert.ok(!args({ meta: { title: 'x' } }).some((v) => /^comment=/.test(v)), 'boş açıklama yazılmaz'); +}); + +test('dosya adı: yasak karakter, ayrılmış ad, sondaki nokta', () => { + assert.strictEqual(F.sanitizeFileName('a:c"d/e\\f|g?h*i. '), 'a b c d e f g h i'); + assert.strictEqual(F.sanitizeFileName('CON'), 'export'); + assert.strictEqual(F.sanitizeFileName('lpt1', 'yedek'), 'yedek'); + assert.strictEqual(F.sanitizeFileName(' '), 'export'); + assert.strictEqual(F.sanitizeFileName('x'.repeat(400)).length, 150); + assert.strictEqual(F.outputName({ title: 'Song', scene: 'Club', bpm: 128 }, 'mp4'), 'Song - Club - 128 BPM.mp4'); + assert.strictEqual(F.outputName({ title: 'Song' }, 'mov'), 'Song.mov'); + assert.strictEqual(F.outputName({ title: 'A:B', scene: '' }, 'gif'), 'A B.gif'); +}); + +test('geçici ad: uzantı sonda kalıyor', () => { + assert.strictEqual(F.partialPath('C:\\v\\a.mp4'), 'C:\\v\\a.partial.mp4'); + assert.strictEqual(F.partialPath('/v/x.y/clip'), '/v/x.y/clip.partial'); + assert.strictEqual(F.partialPath('/v/a.b.webm'), '/v/a.b.partial.webm'); +}); + +test('kalan süre ilk saniyeden sonra', () => { + assert.strictEqual(F.progressStats(10, 100, 500).eta, null); + assert.deepStrictEqual(F.progressStats(300, 1200, 5000), { pct: 25, fps: 60, eta: 15 }); + assert.strictEqual(F.progressStats(5, 0, 0).pct, 100); +}); + +test('ana süreç: geçici çıktı, başarıda taşıma, iptalde silme, kapanışta durdurma', () => { + const M = read('src/main/main.js'); + const job = M.slice(M.indexOf('async function startExportJob('), M.indexOf("ipcMain.handle('export:start'")); + assert.match(job, /target = exportFormats\.partialPath\(outputPath\);/); + assert.match(job, /workDir = seqDir \+ '\.partial';/); + assert.match(job, /audioSec, durationSec, output: target,/); + const fin = M.slice(M.indexOf('function finalizeExport('), M.indexOf("notifyAdmin('export-done'")); + assert.ok(fin.indexOf('promotePartial(') > 0 && fin.indexOf("status = 'error';") > fin.indexOf('promotePartial(')); + assert.match(fin, /else if \(status !== 'done' && partial && !\(opts && opts\.keep\)\)/); + assert.match(M, /function shutdownCleanup\(\) \{[\s\S]{0,400}if \(exportState\) finalizeExport\('cancelled'\);/); + // Dışa aktarıcı süreyi işten alıyor + assert.match(read('src/exporter/exporter.js'), /const renderSec = Number\(job\.durationSec\) > 0 \? Number\(job\.durationSec\) : duration;/); +}); + +test('MCP: süre ve sahne adı şemada', () => { + const mcp = require('../src/shared/mcp.js'); + const p = mcp.tools('en').find((t) => t.name === 'sv_start_export').inputSchema.properties; + assert.strictEqual(p.duration.type, 'object'); + assert.strictEqual(p.sceneName.type, 'string'); +}); + +test('panel: süre kaynağı, kalan süre ve çevirileri', () => { + const A = read('src/admin/admin.js'); + assert.match(A, /type: 'select', path: 'export\.duration\.mode'/); + assert.match(A, /show: \(\) => exportDuration\(\)\.mode === 'seconds'/); + assert.match(A, /if \(d\.eta != null\) line \+= ' · kalan ' \+ fmtClock\(d\.eta\);/); + const I = read('src/shared/i18n.js'); + for (const k of ['Ses dosyasının süresi', 'Sabit süre', 'Zaman çizelgesinin uzunluğu', 'Süre (saniye)']) { + assert.ok(I.includes("'" + k + "':"), k); + } + assert.match(I, /\.replace\(\/\^kalan \(\\d\+\(\?::\\d\\d\)\{1,2\}\)\$\/, '\$1 left'\)/); +}); diff --git a/tests/export-quit.test.js b/tests/export-quit.test.js index c05a0ef..c8d64b4 100644 --- a/tests/export-quit.test.js +++ b/tests/export-quit.test.js @@ -77,7 +77,11 @@ test('silinemeyen dosya kapanışı en çok verilen süre kadar bekletir', () => test('dışa aktarım sonu ve will-quit yeni silme yolunu kullanır', () => { const fin = M.slice(M.indexOf('function finalizeExport('), M.indexOf("notifyAdmin('export-done'")); - assert.match(fin, /removePartial\(out, proc\);/); + // Silinen GEÇİCİ çıktı (#532); asıl yolda yarım dosya hiç olmuyor + assert.match(fin, /removePartial\(partial, proc\);/); + assert.match(fin, /promotePartial\(partial, seqDir \? workDir : null, seqDir \|\| out\)/); + const sc = M.slice(M.indexOf('function shutdownCleanup() {'), M.indexOf('function shutdownCleanup() {') + 400); + assert.match(sc, /if \(exportState\) finalizeExport\('cancelled'\);/, 'kapanışta süren iş durur'); assert.ok(!/setTimeout\(\(\) => \{ try \{ fs\.existsSync\(out\)/.test(fin), 'eski tek seferlik zamanlayıcı'); const wq = M.slice(M.indexOf("app.on('will-quit', () => {"), M.indexOf('if (pendingExitCode) app.exit(pendingExitCode);')); assert.match(wq, /flushPartialsSync\(1500\);/, 'app.exit öncesinde silinmeli');