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