From 84173297662cc1111061eb50e580dc36c27f7242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 03:50:20 +0300 Subject: [PATCH 1/2] export: split at timeline markers into named chapter files from one render (#527) Forced keyframes at the markers and the segment muxer cut one render, so feedback layers stay continuous across chapters. Segment split times sit half a frame early; at the exact time x264 skipped a cut. AMF ignores forced keyframes and is not picked for chapters. The bench driver now holds panel config updates, which overwrote the first job's settings. Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- src/admin/admin.js | 7 +++ src/main/main.js | 65 +++++++++++++++++++++++- src/shared/defaults.js | 1 + src/shared/export-formats.js | 74 ++++++++++++++++++++++++--- src/shared/i18n.js | 1 + src/shared/mcp.js | 1 + tests/export-chapters.test.js | 96 +++++++++++++++++++++++++++++++++++ tests/export-job.test.js | 3 +- tests/smoke-hold.test.js | 10 +++- 9 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 tests/export-chapters.test.js diff --git a/src/admin/admin.js b/src/admin/admin.js index 1740e86..777e7cd 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -3425,6 +3425,12 @@ min: 0, max: 300, step: 1, fmt: (v) => (Math.round(v) > 0 ? String(Math.round(v)) : 'Zaman çizelgesi'), show: () => exportDuration().mode === 'bars', }, + { + /* Bölümler (#527): tek render, zaman çizelgesi işaretçilerinde + kesilip işaretçi adlarıyla bir klasöre. Yalnız video biçimleri. */ + type: 'toggle', path: 'export.chapters', label: 'İşaretçilerden bölümlere ayır', + show: () => { const f = exportFormat(); return !f || f.kind === 'video'; }, + }, { type: 'select', path: 'export.resolution', label: 'Çözünürlük', rebuild: true, // profil seçimi güncellensin (#522) options: [ @@ -5830,6 +5836,7 @@ outputPath, format: fmtId, duration: { mode: dur.mode, seconds: dur.seconds, bars: dur.bars, bpm: dur.bpm }, + chapters: !!(cfg.export && cfg.export.chapters) && (!fmt || fmt.kind === 'video'), sceneName, resolution: cfg.export.resolution, fps: cfg.export.fps, diff --git a/src/main/main.js b/src/main/main.js index ef9381a..f68a922 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -4290,6 +4290,27 @@ function removePartialDir(dir, proc) { if (proc && proc.exitCode == null && proc.signalCode == null) proc.once('close', () => setTimeout(drop, 50)); else setTimeout(drop, 50); } +/* Segment yazıcısının 001.mp4, 002.mp4… dosyalarına işaretçi adı verir + (#527). Sayı tutmazsa iş hata sayılıyor: eksik bölüm sessizce geçmesin. */ +function nameChapters(dir, names, ext) { + let files; + try { + files = fs.readdirSync(dir).filter((f) => /^\d{3}\./.test(f)).sort(); + } catch (err) { + return { ok: false, error: err.message }; + } + if (files.length !== names.length) { + return { ok: false, error: trUi('Bölüm sayısı tutmuyor: ', 'Chapter count does not match: ') + files.length + ' / ' + names.length }; + } + const word = trUi('Bölüm', 'Chapter'); + try { + files.forEach((f, i) => fs.renameSync(path.join(dir, f), path.join(dir, exportFormats.chapterFileName(i, names[i], ext, word)))); + } catch (err) { + return { ok: false, error: err.message }; + } + return { ok: true }; +} + /* 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 @@ -4481,7 +4502,10 @@ async function startExportJob(opts, bench) { (NVIDIA, AMD, Intel sırasıyla), yoksa CPU. Deneme kodlamasını geçmemiş bir kodlayıcı hiç seçilmez. */ const okMap = await probeEncoders(); - const encoderId = exportFormats.pickEncoder(fmt, opts.encoder === 'gpu' ? 'gpu' : 'cpu', (e) => okMap[e]); + // Bölümleme zorunlu anahtar kare istiyor; AMF onu yok sayıyor (#527) + const wantChapters = opts.chapters === true && fmt.kind === 'video'; + const encoderId = exportFormats.pickEncoder(fmt, opts.encoder === 'gpu' ? 'gpu' : 'cpu', (e) => okMap[e], + { forcedKeys: wantChapters }); if (!encoderId) { return { ok: false, error: trUi('Bu biçimin kodlayıcısı bu bilgisayarda çalışmıyor: ', 'No working encoder for this format on this computer: ') + fmt.id }; } @@ -4558,6 +4582,29 @@ async function startExportJob(opts, bench) { /* "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; + + /* Bölümler (#527): TEK render, işaretçilerde zorunlu anahtar kare ve + ffmpeg'in segment yazıcısıyla kesim. Her bölümü ayrı çizmek MilkDrop + gibi geri beslemeli katmanları sınırda farklı başlatırdı. Dosyalar bir + klasöre gidiyor ve işaretçinin adını alıyor; kare dizisindeki gibi önce + `.partial` klasörüne. */ + let chapters = null; + if (wantChapters) { + let markers = []; + try { markers = TL.makeTimeline(tlCfg).markers; } catch { markers = []; } + chapters = exportFormats.chapterPlan(markers, dur.seconds, fps, trUi('Başlangıç', 'Start')); + if (!chapters.cuts.length) { + return { ok: false, error: trUi('Bölmek için zaman çizelgesinde bu süre içinde bir işaretçi yok.', + 'There is no timeline marker within this length to split at.') }; + } + 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 + ')'); + workDir = seqDir + '.partial'; + for (let n = 2; fs.existsSync(workDir) && n < 1000; n++) workDir = seqDir + '.partial' + n; + target = workDir.replace(/%/g, '%%') + '/%03d.' + fmt.ext; + } /* 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)) { @@ -4576,6 +4623,7 @@ async function startExportJob(opts, bench) { 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), audioSec, durationSec, output: target, + chapters: chapters ? chapters.cuts : null, meta: { title: track.title, artist: track.artist, album: track.album, scene: sceneName, bpm: bpmMeta }, }); @@ -4596,6 +4644,7 @@ async function startExportJob(opts, bench) { exportState = { outputPath: seqDir || outputPath, seqDir, workDir, partial: workDir || target, format: fmt.id, encoderId, ffErr: '', encoder, finishAt: 0, cancel: false, + chapterNames: chapters ? chapters.names : null, ext: fmt.ext, startedAt: 0, total: 0, }; /* Ölçüm (#531): ana süreç tarafındaki yazma/bekleme süreleri ve kare @@ -4796,6 +4845,8 @@ function finalizeExport(status, message, opts) { const seqDir = exportState.seqDir; const workDir = exportState.workDir; const partial = exportState.partial; + const chapterNames = exportState.chapterNames; + const ext = exportState.ext; const encoder = exportState.encoder; const encoderId = exportState.encoderId; const prof = exportState.prof; @@ -4833,6 +4884,10 @@ function finalizeExport(status, message, opts) { hatada geçici çıktı siliniyor; asıl yolda hiçbir zaman yarım dosya olmuyor. */ let finalOut = out; + if (status === 'done' && chapterNames && workDir) { + const named = nameChapters(workDir, chapterNames, ext); + if (!named.ok) { status = 'error'; message = named.error; } + } if (status === 'done' && partial) { const moved = promotePartial(partial, seqDir ? workDir : null, seqDir || out); if (moved.ok) { @@ -4850,6 +4905,8 @@ function finalizeExport(status, message, opts) { finalOut = partial; } + // Ölçüm kaydı SON durumu taşısın: ad verme ya da taşıma işi hataya çevirebiliyor + if (prof && lastExportProfile) { lastExportProfile.status = status; lastExportProfile.message = message || ''; } notifyAdmin('export-done', { status, output: finalOut, message: message || '', encoder, encoderLabel: encoderId && exportFormats.ENCODERS[encoderId] ? exportFormats.ENCODERS[encoderId].label : '', @@ -5072,6 +5129,11 @@ app.whenReady().then(async () => { async function runExportBench(file) { const spec = JSON.parse(fs.readFileSync(file, 'utf8')); const results = []; + /* Panel açılırken kendi ayarlarını gönderiyor (update-config) ve bu, + işin yapılandırmasını DEĞİŞTİRİYORDU: ilk iş bazen panelin ayarıyla + çizildi (farklı kare özeti), bölümleme ilk işte işaretçi göremedi. + Ölçüm boyunca panelin güncellemeleri öz testteki gibi bekletiliyor. */ + smokeHoldStart(); const base = currentConfig; for (const job of spec.jobs || []) { /* Profilin kayıtlı ayarları MİRAS ALINMIYOR: dışa aktarıcı iş @@ -5096,6 +5158,7 @@ async function runExportBench(file) { results.push(row); } currentConfig = base; + smokeHoldEnd(); fs.writeFileSync(spec.result, JSON.stringify(results, null, 2)); app.exit(0); } diff --git a/src/shared/defaults.js b/src/shared/defaults.js index e386b0f..3bb6c5b 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -1176,6 +1176,7 @@ /* 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 }, + chapters: false, // zaman çizelgesi işaretçilerinden bölümlere ayır (#527) 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 ea233e5..61efe4a 100644 --- a/src/shared/export-formats.js +++ b/src/shared/export-formats.js @@ -39,11 +39,13 @@ const ENCODERS = { libx264: { codec: 'h264', gpu: false, label: 'CPU — x264' }, h264_nvenc: { codec: 'h264', gpu: true, label: 'NVIDIA NVENC' }, - h264_amf: { codec: 'h264', gpu: true, label: 'AMD AMF' }, + /* AMF zorunlu anahtar kareyi yok sayıyor (ölçüldü: işaretçilerde + kesilmedi, tek dosya çıktı); bölümlemede seçilmiyor (#527). */ + h264_amf: { codec: 'h264', gpu: true, label: 'AMD AMF', noForcedKeys: true }, h264_qsv: { codec: 'h264', gpu: true, label: 'Intel Quick Sync' }, libx265: { codec: 'hevc', gpu: false, label: 'CPU — x265' }, hevc_nvenc: { codec: 'hevc', gpu: true, label: 'NVIDIA NVENC' }, - hevc_amf: { codec: 'hevc', gpu: true, label: 'AMD AMF' }, + hevc_amf: { codec: 'hevc', gpu: true, label: 'AMD AMF', noForcedKeys: true }, hevc_qsv: { codec: 'hevc', gpu: true, label: 'Intel Quick Sync' }, prores_ks: { codec: 'prores', gpu: false, label: 'ProRes' }, dnxhd: { codec: 'dnxhr', gpu: false, label: 'DNxHR' }, @@ -245,9 +247,11 @@ function encodersFor(fmt) { return ORDER.filter(function (e) { return ENCODERS[e].codec === fmt.codec; }); } - function pickEncoder(fmt, want, ok) { + function pickEncoder(fmt, want, ok, needs) { const list = encodersFor(fmt); - const can = typeof ok === 'function' ? ok : function () { return true; }; + const okFn = typeof ok === 'function' ? ok : function () { return true; }; + const forced = !!(needs && needs.forcedKeys); + const can = function (e) { return okFn(e) && !(forced && ENCODERS[e].noForcedKeys); }; if (want === 'gpu') { for (let i = 0; i < list.length; i++) if (ENCODERS[list[i]].gpu && can(list[i])) return list[i]; } @@ -305,6 +309,21 @@ } if (aArgs) args.push('-map', '1:a:0'); args.push.apply(args, v.args); + /* Bölümler (#527): kesim anlarında zorunlu anahtar kare. NVENC ve QSV + bunu IDR yapmazsa bölüm bir önceki dosyaya bağımlı başlar. */ + const cuts = Array.isArray(o.chapters) && o.chapters.length && fmt.kind === 'video' ? o.chapters : null; + const cutList = cuts ? cuts.map((t) => Number(t).toFixed(6)).join(',') : ''; + /* Yazıcının kesim zamanı YARIM KARE ÖNCE: kesim, o andan sonraki ilk + anahtar karede yapılıyor ve tam zamanda verilince paket zamanı bir + tık altında kalan kare atlanıyordu (x264, 3 yerine 2 bölüm). + Ölçüldü: x264, NVENC, x265, VP9, ProRes'te bölümler 60/45/45 kare. */ + const half = 0.5 / (Number(o.fps) > 0 ? Number(o.fps) : 30); + const segList = cuts ? cuts.map((t) => Math.max(0, Number(t) - half).toFixed(6)).join(',') : ''; + if (cuts) { + args.push('-force_key_frames', cutList); + if (/_nvenc$/.test(o.encoder)) args.push('-forced-idr', '1'); + if (/_qsv$/.test(o.encoder)) args.push('-forced_idr', '1'); + } if (v.tags) args.push.apply(args, BT709_TAGS); if (aArgs) { if (pad) args.push('-af', 'apad'); @@ -324,10 +343,15 @@ 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 (!cuts && (fmt.ext === 'mp4' || fmt.ext === 'mov')) args.push('-movflags', '+faststart'); 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 (cuts) { + args.push('-f', 'segment', '-segment_times', segList, '-reset_timestamps', '1', + '-segment_format', fmt.ext, '-segment_start_number', '1'); + if (fmt.ext === 'mp4' || fmt.ext === 'mov') args.push('-segment_format_options', 'movflags=+faststart'); + } if (fmt.id === 'apng') args.push('-f', 'apng'); args.push(o.output); return args; @@ -393,6 +417,44 @@ return Object.assign({}, exp || {}, p ? p.set : {}); } + // ----------------------------------------------------------- bölümler (#527) + + /* Zaman çizelgesi işaretçilerinden bölüm planı. Kesimler kare sınırına + yuvarlanıyor; 0'daki ya da son karedeki işaretçi kesim değil (ilk + işaretçi 0'daysa ilk bölüm onun adını alıyor), aynı kareye düşenler + tekleniyor. Bölüm k, k'inci kesimden bir sonrakine kadar sürüyor. + markers: [{ t, name }]. Dönen: { cuts: [s…], names: [ad…] } — + names.length === cuts.length + 1. */ + function chapterPlan(markers, durationSec, fps, firstName) { + const f = Number(fps) > 0 ? Number(fps) : 30; + const end = Math.round((Number(durationSec) || 0) * f); + const list = (Array.isArray(markers) ? markers : []) + .map((m) => ({ frame: Math.round((Number(m && m.t) || 0) * f), name: String((m && m.name) || '').trim() })) + .filter((m) => m.frame >= 0 && m.frame < end) + .sort((a, b) => a.frame - b.frame); + const cuts = []; + const names = []; + const atZero = list.find((m) => m.frame === 0); + names.push(atZero && atZero.name ? atZero.name : String(firstName || '')); + for (const m of list) { + if (m.frame === 0) continue; + if (cuts.length && Math.round(cuts[cuts.length - 1] * f) === m.frame) { + if (m.name && !names[names.length - 1]) names[names.length - 1] = m.name; + continue; + } + cuts.push(m.frame / f); + names.push(m.name); + } + return { cuts, names }; + } + + /* Bölüm dosyasının adı: "01 - Nakarat.mp4". Adsız işaretçi "Bölüm N". */ + function chapterFileName(index, name, ext, fallbackWord) { + const n = String(index + 1).padStart(2, '0'); + const label = sanitizeFileName(name, (fallbackWord || 'Chapter') + ' ' + (index + 1)); + return n + ' - ' + label + '.' + ext; + } + // ------------------------------------------------------------ süre (#523) const DURATION_MODES = ['audio', 'seconds', 'timeline', 'bars']; @@ -470,7 +532,7 @@ RESOLUTIONS, FPS, QUALITIES, SPEEDS, ENCODERS, FORMATS, DEFAULT_FORMAT, format, extensions, encodersFor, pickEncoder, available, videoArgs, audioArgs, buildArgs, probeArgs, sequencePattern, takesYuv420, - PROFILES, profile, matchProfile, applyProfile, + PROFILES, profile, matchProfile, applyProfile, chapterPlan, chapterFileName, DURATION_MODES, MAX_SECONDS, resolveDuration, sanitizeFileName, outputName, partialPath, progressStats, }; diff --git a/src/shared/i18n.js b/src/shared/i18n.js index c1b77b7..c5ccbd6 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2966,6 +2966,7 @@ /* Dışa aktarma biçimleri (#521); export-formats.js etiketleriyle aynı */ 'Dikey — 1080×1920 (Reels / Shorts)': 'Vertical — 1080×1920 (Reels / Shorts)', /* Dışa aktarma profilleri (#522); export-formats.js etiketleriyle aynı */ + 'İşaretçilerden bölümlere ayır': 'Split into chapters at the markers', 'Hazır Profil': 'Preset Profile', 'Instagram kare — 1080×1080': 'Instagram square — 1080×1080', 'Twitch / OBS bindirme — WebM alfa': 'Twitch / OBS overlay — WebM alpha', 'Ana kopya 4K — ProRes 422 HQ': 'Master 4K — ProRes 422 HQ', 'GIF döngü — 720p': 'GIF loop — 720p', diff --git a/src/shared/mcp.js b/src/shared/mcp.js index 96f86da..62c11d8 100644 --- a/src/shared/mcp.js +++ b/src/shared/mcp.js @@ -1084,6 +1084,7 @@ 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.'), + chapters: sp('boolean', 'Split the video at the timeline markers into a folder of files named after the markers. One render; cuts land on forced keyframes. Video formats only.'), 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-chapters.test.js b/tests/export-chapters.test.js new file mode 100644 index 0000000..9e05814 --- /dev/null +++ b/tests/export-chapters.test.js @@ -0,0 +1,96 @@ +'use strict'; +/* İŞARETÇİLERDEN BÖLÜMLER (#527). + * + * Tek render; işaretçi anlarında zorunlu anahtar kare ve ffmpeg'in segment + * yazıcısıyla kesim. Ayrı ayrı çizmek MilkDrop gibi geri beslemeli katmanları + * her bölümün başında soğuk başlatırdı. + * + * Ölçüldü (5 sn, işaretçiler 2,0 ve 3,5'te, 30 fps): x264, NVENC, x265, VP9 + * ve ProRes'te bölümler 60/45/45 kare, her biri anahtar kareyle başlıyor. + * AMF zorunlu anahtar kareyi yok sayıyor (tek dosya), bölümlemede seçilmiyor. + */ +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]; + +test('bölüm planı: kare sınırı, tekleme, 0 ve sondaki işaretçi', () => { + const p = F.chapterPlan([ + { t: 4, name: 'B' }, { t: 0, name: 'Intro' }, { t: 4.01, name: 'aynı kare' }, + { t: 9, name: '' }, { t: 12, name: 'sonda' }, { t: 20, name: 'dışarıda' }, + ], 12, 30, 'Start'); + assert.deepStrictEqual(p.cuts, [4, 9]); + assert.deepStrictEqual(p.names, ['Intro', 'B', '']); + assert.strictEqual(p.names.length, p.cuts.length + 1); + // 0'da işaretçi yoksa ilk bölüm yedek adı alıyor + assert.deepStrictEqual(F.chapterPlan([{ t: 2.5, name: 'X' }], 6, 30, 'Start').names, ['Start', 'X']); + // Kesim kare sınırına yuvarlanıyor (30 fps: 2,51 -> 75. kare = 2,5) + assert.deepStrictEqual(F.chapterPlan([{ t: 2.51, name: 'X' }], 6, 30, '').cuts, [2.5]); + assert.deepStrictEqual(F.chapterPlan([], 6, 30, '').cuts, []); +}); + +test('bölüm dosya adı: sıra, temizlenmiş ad, adsıza yedek', () => { + assert.strictEqual(F.chapterFileName(0, 'Nakarat: 1', 'mp4', 'Bölüm'), '01 - Nakarat 1.mp4'); + assert.strictEqual(F.chapterFileName(2, '', 'webm', 'Bölüm'), '03 - Bölüm 3.webm'); + assert.strictEqual(F.chapterFileName(9, 'CON', 'mov', 'Chapter'), '10 - Chapter 10.mov'); +}); + +test('argümanlar: anahtar kare tam zamanda, kesim yarım kare önce, faststart segmentte', () => { + const a = F.buildArgs({ + fmt: F.format('mp4-h264'), encoder: 'h264_nvenc', w: 64, h: 64, fps: 30, quality: 'high', speed: 'fast', + output: 'D:/o/%03d.mp4', chapters: [2, 3.5], + }); + assert.strictEqual(after(a, '-force_key_frames'), '2.000000,3.500000'); + assert.strictEqual(after(a, '-forced-idr'), '1', 'NVENC anahtar kareyi IDR yapmalı'); + assert.strictEqual(after(a, '-segment_times'), '1.983333,3.483333'); + assert.strictEqual(a[a.lastIndexOf('-f') + 1], 'segment'); + assert.strictEqual(after(a, '-segment_format'), 'mp4'); + assert.strictEqual(after(a, '-segment_format_options'), 'movflags=+faststart'); + assert.ok(!a.includes('-movflags'), 'faststart segmentin içinde'); + // Bölüm yalnız video biçimlerinde + const g = F.buildArgs({ fmt: F.format('gif'), encoder: 'gif', w: 64, h: 64, fps: 24, quality: 'high', speed: 'fast', output: 'x.gif', chapters: [2] }); + assert.ok(!g.includes('segment') && !g.includes('-force_key_frames')); +}); + +test('bölümlemede AMF seçilmiyor, sıradaki uygun kodlayıcı', () => { + const f = F.format('mp4-h264'); + const noNvenc = (e) => e !== 'h264_nvenc'; + assert.strictEqual(F.pickEncoder(f, 'gpu', noNvenc), 'h264_amf'); + assert.strictEqual(F.pickEncoder(f, 'gpu', (e) => e === 'h264_amf' || e === 'libx264', { forcedKeys: true }), 'libx264'); + assert.strictEqual(F.pickEncoder(F.format('mp4-hevc'), 'gpu', (e) => e === 'hevc_amf' || e === 'libx265', { forcedKeys: true }), 'libx265'); + for (const e of ['h264_amf', 'hevc_amf']) assert.strictEqual(F.ENCODERS[e].noForcedKeys, true, e); +}); + +test('ana süreç: tek render, klasör, işaretçi adları, sayı tutmazsa hata', () => { + const M = read('src/main/main.js'); + assert.match(M, /const wantChapters = opts\.chapters === true && fmt\.kind === 'video';/); + assert.match(M, /\{ forcedKeys: wantChapters \}/); + assert.match(M, /chapters = exportFormats\.chapterPlan\(markers, dur\.seconds, fps,/); + assert.match(M, /chapters: chapters \? chapters\.cuts : null,/); + const nc = M.slice(M.indexOf('function nameChapters('), M.indexOf('function promotePartial(')); + assert.match(nc, /if \(files\.length !== names\.length\)/, 'eksik bölüm sessizce geçmez'); + const fin = M.slice(M.indexOf('function finalizeExport('), M.indexOf("notifyAdmin('export-done'")); + assert.ok(fin.indexOf('nameChapters(') < fin.indexOf('promotePartial('), 'önce ad, sonra taşıma'); +}); + +test('ölçüm sürücüsü panel güncellemelerini bekletiyor', () => { + /* Panel açılırken update-config ile yapılandırmayı değiştiriyordu: ilk + iş panelin ayarıyla çiziliyordu (bölümleme ilk işte işaretçi göremedi). */ + const M = read('src/main/main.js'); + const b = M.slice(M.indexOf('async function runExportBench('), M.indexOf('async function runExportBench(') + 5000); + assert.ok(b.indexOf('smokeHoldStart();') > 0 && b.indexOf('smokeHoldStart();') < b.indexOf('for (const job of')); + assert.match(b, /smokeHoldEnd\(\);/); +}); + +test('panel ve MCP', () => { + const A = read('src/admin/admin.js'); + assert.match(A, /type: 'toggle', path: 'export\.chapters', label: 'İşaretçilerden bölümlere ayır'/); + assert.match(A, /chapters: !!\(cfg\.export && cfg\.export\.chapters\) && \(!fmt \|\| fmt\.kind === 'video'\),/); + assert.ok(read('src/shared/i18n.js').includes("'İşaretçilerden bölümlere ayır': 'Split into chapters at the markers'")); + const mcp = require('../src/shared/mcp.js'); + assert.strictEqual(mcp.tools('en').find((t) => t.name === 'sv_start_export').inputSchema.properties.chapters.type, 'boolean'); +}); diff --git a/tests/export-job.test.js b/tests/export-job.test.js index 1807d61..55c6a8d 100644 --- a/tests/export-job.test.js +++ b/tests/export-job.test.js @@ -91,7 +91,8 @@ test('ana süreç: geçici çıktı, başarıda taşıma, iptalde silme, kapanı 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(')); + // Taşınamazsa iş hataya dönüyor (taşımadan SONRAKİ ilk hata ataması) + assert.ok(fin.indexOf('promotePartial(') > 0 && fin.indexOf("status = 'error';", fin.indexOf('promotePartial(')) > 0); 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 diff --git a/tests/smoke-hold.test.js b/tests/smoke-hold.test.js index 49d5f29..81a6ddf 100644 --- a/tests/smoke-hold.test.js +++ b/tests/smoke-hold.test.js @@ -23,9 +23,15 @@ test('panel gönderimleri bekletilirken saklanır, bitince uygulanır', () => { assert.ok(end.indexOf('applyIncomingConfig(h.full)') < end.indexOf('applyConfigPatch(p)'), 'önce tam yapılandırma, sonra yamalar'); }); -test('bekletmeyi yalnız öz test açar ve sprite ölçümünü sarar', () => { +test('bekletmeyi yalnız öz test ve dışa aktarma ölçümü açar, sprite ölçümünü sarar', () => { + /* İki çağıran: öz test ve --bench-export (#527: panelin açılış + güncellemesi ölçülen işin yapılandırmasını değiştiriyordu). */ const starts = SRC.split('smokeHoldStart();').length - 1; - assert.strictEqual(starts, 1, 'tek çağıran'); + assert.strictEqual(starts, 2, 'iki çağıran'); + const bench = SRC.indexOf('async function runExportBench('); + const inBench = SRC.indexOf('smokeHoldStart();', bench); + const fnEnd = /\r?\n\}\r?\n/.exec(SRC.slice(bench)); // CRLF ile çekilen kopyada da + assert.ok(bench > 0 && fnEnd && inBench > bench && inBench < bench + fnEnd.index, 'ikincisi runExportBench içinde'); assert.match(SRC, /smokeHoldStart\(\);\n let spr;\n try \{ spr = await spriteProbe\(\); \} finally \{ smokeHoldEnd\(\); \}/); const at = SRC.indexOf('smokeHoldStart();\n let spr;'); assert.ok(at > SRC.indexOf('async function runSmoke()'), 'runSmoke içinde'); From 02f34f0da00c1b1be443973225bfedb120793755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 03:51:25 +0300 Subject: [PATCH 2/2] docs: chapters at markers, test count (#527) Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ README.md | 8 +++++--- README.tr.md | 9 ++++++--- ROADMAP.md | 22 ++++++++++++++++++++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fee5e1..8fe219c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ GitHub. The full reasoning and the measurements behind each change are in [ROADM CPU choice and the speed balance stay yours. The profile is read from the settings themselves, so changing any of them by hand shows "Custom", and every setting stays available underneath. MCP's `sv_start_export` takes a `profile` too, with explicit arguments taking precedence. +- **Split a long show at the timeline markers [527].** One render is cut at forced keyframes into + a folder of files named after the markers ("01 - Intro.mp4", "02 - Drop.mp4"), so feedback + layers like MilkDrop run continuously across the cuts instead of restarting cold in each part. + Measured on H.264 (x264 and NVENC), H.265, VP9 and ProRes: chapters of exactly 60, 45 and 45 + frames for markers at 2 s and 3.5 s, each starting on a keyframe. AMD's AMF ignores forced + keyframes, so chapters use the next encoder that honours them. - **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 diff --git a/README.md b/README.md index 0aef627..4b872c9 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-3138%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3145%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -935,7 +935,9 @@ the fixtures on the left take the colours on the left of the screen. 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. 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. + BPM, and a cancelled export leaves no half-written file. One-click profiles cover YouTube, + Reels / Shorts, Instagram, a Twitch overlay with alpha and a ProRes master, and a long show can be + split at the timeline's markers into files named after them, from a single render. - **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. @@ -1715,7 +1717,7 @@ npm test npm start -- --smoke ``` -**3138 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3145 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 b065a39..9bb70b6 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-3138%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3145%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -934,7 +934,10 @@ ekranın solundaki renkleri alıyor. 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. 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. + etiketleniyor, iptal edilen dışa aktarım yarım dosya bırakmıyor. Tek tıklık profiller YouTube, + Reels / Shorts, Instagram, alfalı Twitch bindirmesi ve ProRes ana kopyayı kapsıyor; uzun bir + gösteri tek render'dan zaman çizelgesinin işaretçilerinde, onların adlarını taşıyan dosyalara + bölünebiliyor. - Çı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**. @@ -1718,7 +1721,7 @@ npm test npm start -- --smoke ``` -**3138 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3145 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 c43d321..02afdf9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,7 +93,7 @@ hands, and a row only moves up once it ships. |---|:--:|---| | Per-app audio capture on macOS and Linux | v3.1.6 | On `main` (#514, #517). Linux verified in WSLg with PulseAudio; macOS written on ScreenCaptureKit and compiled in CI, never run on a Mac. Windows shipped in v3.1.3 | | Live level meter per application | v3.1.6 | Done on `main` (#516): a moving dB-scale bar per application, streamed only while the picker is on screen | -| Comprehensive video export | v3.1.6 | Formats, alpha, frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522) done; queue, chapters and the two render modes in progress | +| Comprehensive video export | v3.1.6 | Formats, alpha, frame sequences (#521, #525, #529), about 3× faster export (#531), duration sources, metadata and clean cancel (#523, #524, #528, #532), profiles (#522), chapters (#527) done; queue 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 ``` -- **3138 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3145 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,24 @@ 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. +### Chapters at the timeline markers (#527) · done + +- **One render, cut afterwards.** The markers inside the export become forced keyframes, and ffmpeg's segment muxer cuts the single render at them. Rendering each chapter on its own would start MilkDrop and other feedback layers cold at every boundary, so the cut would show. +- **Output.** + - Files go to a folder named after the chosen file, one per chapter, named from the marker: `01 - Intro.mp4`, `02 - Drop.mp4`. + - A marker at 0 names the first chapter. Without one, the first chapter is called "Start". + - Markers that land on the same frame count once. + - The folder is written as `.partial` and renamed when the job succeeds, like a frame sequence. + - If the number of files does not match the plan, the job fails instead of silently missing a chapter. +- **Two measured traps:** + - **Exact split times drop cuts.** Given the exact time, the segment muxer skipped the cut whose packet time rounded a hair below it: x264 produced 2 files instead of 3. Split times are now half a frame early, and the keyframe stays at the exact frame. + - **AMF ignores forced keyframes.** It produced one file, and none of its options enforces an IDR at a given time. Chapters therefore pick the next GPU encoder that honours forced keyframes (NVENC with `-forced-idr 1`, Quick Sync), and fall back to the CPU. +- **Measured.** 5 s of audio, markers at 2.0 s ("Nakarat") and 3.5 s ("Son: Bitiş"), 30 fps. + - x264, NVENC, x265, VP9 and ProRes each give 60 / 45 / 45 frames, and every file starts on a keyframe. + - The names come out as `01 - Başlangıç`, `02 - Nakarat` and `03 - Son Bitiş`. + - Audio is cut on AAC frame boundaries, so it differs from the video by up to a few tens of milliseconds per file: 2.043 / 1.486 / 1.494 s against 2.0 / 1.5 / 1.5 s. +- **Found on the way.** The bench driver's first job sometimes ran on the panel's settings instead of its own, because the panel sends its config while it loads. This is what produced the odd first-job hash noted under #531. The driver now holds panel updates the same way the self-test does. + ### Preset profiles (#522) · done - **The profiles are data** in `export-formats.js`: YouTube 1080p60, YouTube 4K60 (H.265), Reels / Shorts / TikTok 1080×1920 at 30 fps, Instagram square 1080×1080, a Twitch / OBS overlay as 1080p60 WebM VP9 with alpha, a 4K60 ProRes 422 HQ master, and a 720p GIF loop at 24 fps.