From 67b8ad2e966f354fdd0baadbc0ff7e873d68a8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 03:40:37 +0300 Subject: [PATCH 1/2] export: preset profiles derived from the settings, MCP profile argument (#522) Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- src/admin/admin.js | 36 +++++++++++-- src/shared/export-formats.js | 44 ++++++++++++++++ src/shared/i18n.js | 4 ++ src/shared/mcp.js | 10 ++++ tests/export-profiles.test.js | 96 +++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/export-profiles.test.js diff --git a/src/admin/admin.js b/src/admin/admin.js index e706d6b..1740e86 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -847,13 +847,16 @@ } function selectCtrl(def) { - const cur = getPath(cfg, def.path); + /* `value`/`onChange`: ayarda kendi alanı olmayan, başka alanlardan + türeyen seçimler için (dışa aktarma profili). */ + const cur = typeof def.value === 'function' ? def.value() : getPath(cfg, def.path); const opts = typeof def.options === 'function' ? def.options() : def.options; const sel = el('select', { onchange: (e) => { let v = e.target.value; if (def.numeric) v = parseFloat(v); - setPath(cfg, def.path, v); + if (typeof def.onChange === 'function') def.onChange(v); + else setPath(cfg, def.path, v); if (def.rebuild) render(); push(true); }, @@ -3381,6 +3384,19 @@ desc: 'Bir ses dosyası seçin; mevcut sahne ayarlarıyla kayıpsız videoya dönüştürülür. Ekran kaydı değildir — her kare birebir render edilir.', controls: [ { type: 'audiofile' }, + { + /* Hazır profil (#522): ayarların kendisinden türüyor, ayrı bir + alanı yok. Seçmek alanları dolduruyor; bir alan elle + değişince "Özel"e dönüyor. */ + type: 'select', path: 'export.profile', label: 'Hazır Profil', rebuild: true, + value: () => (window.SVExportFormats ? window.SVExportFormats.matchProfile(cfg.export) : 'custom'), + options: () => exportProfileOptions(), + onChange: (id) => { + const FX = window.SVExportFormats; + if (!FX || id === 'custom') return; + cfg.export = FX.applyProfile(cfg.export, id); + }, + }, { type: 'select', path: 'export.format', label: 'Biçim', rebuild: true, options: () => exportFormatOptions(), @@ -3410,7 +3426,7 @@ show: () => exportDuration().mode === 'bars', }, { - type: 'select', path: 'export.resolution', label: 'Çözünürlük', + type: 'select', path: 'export.resolution', label: 'Çözünürlük', rebuild: true, // profil seçimi güncellensin (#522) options: [ { value: '720p', label: '720p — 1280×720' }, { value: '1080p', label: '1080p — 1920×1080 (Full HD)' }, @@ -3421,7 +3437,7 @@ ], }, { - type: 'select', path: 'export.fps', label: 'Kare Hızı (FPS)', numeric: true, + type: 'select', path: 'export.fps', label: 'Kare Hızı (FPS)', numeric: true, rebuild: true, options: () => { const f = exportFormat(); const max = f && f.fpsMax ? f.fpsMax : 60; @@ -3444,7 +3460,7 @@ fallback: () => 'cpu', }, { - type: 'select', path: 'export.quality', label: 'Kalite', + type: 'select', path: 'export.quality', label: 'Kalite', rebuild: true, group: 'Kodlama', advanced: true, options: [ { value: 'visually-lossless', label: 'Görsel Kayıpsız (en yüksek)' }, @@ -3467,6 +3483,16 @@ ]; } + /* Profiller: yalnız biçimi bu makinede çalışanlar. */ + function exportProfileOptions() { + const FX = window.SVExportFormats; + if (!FX) return [{ value: 'custom', label: 'Özel' }]; + const list = (exportCaps && exportCaps.formats) || []; + const ok = (fid) => !list.length || list.some((f) => f.id === fid && f.available); + return [{ value: 'custom', label: 'Özel' }] + .concat(FX.PROFILES.filter((p) => ok(p.set.format)).map((p) => ({ value: p.id, label: p.label[0] }))); + } + /* Süre kaynağı (#523); eski kayıtlarda alan yok. */ function exportDuration() { const e = cfg.export || (cfg.export = {}); diff --git a/src/shared/export-formats.js b/src/shared/export-formats.js index 051f250..ea233e5 100644 --- a/src/shared/export-formats.js +++ b/src/shared/export-formats.js @@ -350,6 +350,49 @@ return args; } + // ---------------------------------------------------------- profiller (#522) + + /* Tek tıkla ayar. Her profil biçimi, çözünürlüğü, kare hızını ve kaliteyi + sabitliyor; renk her biçimde zaten BT.709. Kodlayıcı (GPU/CPU) ve hız + dengesi kullanıcının tercihi olarak kalıyor. Ayrıntılı ayarlar profilin + altında duruyor: bir alan değişince seçim kendiliğinden "Özel" oluyor. */ + const PROFILES = [ + { id: 'youtube-1080p60', label: ['YouTube 1080p60', 'YouTube 1080p60'], + set: { format: 'mp4-h264', resolution: '1080p', fps: 60, quality: 'high' } }, + { id: 'youtube-4k60', label: ['YouTube 4K60 (H.265)', 'YouTube 4K60 (H.265)'], + set: { format: 'mp4-hevc', resolution: '2160p', fps: 60, quality: 'high' } }, + { id: 'reels-shorts', label: ['Reels / Shorts / TikTok — 1080×1920', 'Reels / Shorts / TikTok — 1080×1920'], + set: { format: 'mp4-h264', resolution: '1080x1920', fps: 30, quality: 'high' } }, + { id: 'instagram-square', label: ['Instagram kare — 1080×1080', 'Instagram square — 1080×1080'], + set: { format: 'mp4-h264', resolution: '1080x1080', fps: 30, quality: 'high' } }, + { id: 'twitch-overlay', label: ['Twitch / OBS bindirme — WebM alfa', 'Twitch / OBS overlay — WebM alpha'], + set: { format: 'webm-vp9-alpha', resolution: '1080p', fps: 60, quality: 'high' } }, + { id: 'master-4k', label: ['Ana kopya 4K — ProRes 422 HQ', 'Master 4K — ProRes 422 HQ'], + set: { format: 'mov-prores', resolution: '2160p', fps: 60, quality: 'visually-lossless' } }, + { id: 'gif-loop', label: ['GIF döngü — 720p', 'GIF loop — 720p'], + set: { format: 'gif', resolution: '720p', fps: 24, quality: 'high' } }, + ]; + + function profile(id) { + for (let i = 0; i < PROFILES.length; i++) if (PROFILES[i].id === id) return PROFILES[i]; + return null; + } + + /* Şu anki ayarlara birebir uyan profil, yoksa 'custom'. */ + function matchProfile(exp) { + const e = exp || {}; + for (const p of PROFILES) { + if (Object.keys(p.set).every((k) => String(e[k]) === String(p.set[k]))) return p.id; + } + return 'custom'; + } + + /* Profili ayarlara uygular (yeni nesne); bilinmeyen profilde aynı ayar. */ + function applyProfile(exp, id) { + const p = profile(id); + return Object.assign({}, exp || {}, p ? p.set : {}); + } + // ------------------------------------------------------------ süre (#523) const DURATION_MODES = ['audio', 'seconds', 'timeline', 'bars']; @@ -427,6 +470,7 @@ RESOLUTIONS, FPS, QUALITIES, SPEEDS, ENCODERS, FORMATS, DEFAULT_FORMAT, format, extensions, encodersFor, pickEncoder, available, videoArgs, audioArgs, buildArgs, probeArgs, sequencePattern, takesYuv420, + PROFILES, profile, matchProfile, applyProfile, DURATION_MODES, MAX_SECONDS, resolveDuration, sanitizeFileName, outputName, partialPath, progressStats, }; diff --git a/src/shared/i18n.js b/src/shared/i18n.js index fc617d9..c1b77b7 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -2965,6 +2965,10 @@ '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)', + /* Dışa aktarma profilleri (#522); export-formats.js etiketleriyle aynı */ + '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', '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', diff --git a/src/shared/mcp.js b/src/shared/mcp.js index 3b71bd6..96f86da 100644 --- a/src/shared/mcp.js +++ b/src/shared/mcp.js @@ -925,6 +925,7 @@ const EXPORT_FORMAT_IDS = EXPORT_FX ? EXPORT_FX.FORMATS.map(function (f) { return f.id; }) : ['mp4-h264']; const EXPORT_RESOLUTIONS = EXPORT_FX ? Object.keys(EXPORT_FX.RESOLUTIONS) : ['720p', '1080p', '1440p', '2160p']; const EXPORT_FPS = EXPORT_FX ? EXPORT_FX.FPS.slice() : [30, 60]; + const EXPORT_PROFILE_IDS = EXPORT_FX ? EXPORT_FX.PROFILES.map(function (p) { return p.id; }) : []; const OVERWRITE = sp('boolean', 'Replace the file if it already exists. Default false.'); const MAPPING_KEYS = { enabled: sp('boolean', 'Turn mapping on for this display.'), @@ -1077,6 +1078,7 @@ props: { audioPath: sp('string', 'Absolute path of the audio file.'), outputPath: sp('string', 'Absolute path ending with the extension of the format (.mp4, .mov, .webm, .gif, .png, .exr).'), + profile: sp('string', 'One-click preset for format, resolution, fps and quality; explicit arguments override it.', { enum: EXPORT_PROFILE_IDS }), 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 }), @@ -2055,6 +2057,14 @@ tool('sv_start_export', 'export', 'Start an offline video export. Requires audioPath and outputPath. format picks the container and codec; outputPath must end with the extension of that format. A frame sequence writes into a new folder named after outputPath.', function (args, ctx) { if (!ctx.startExport) return fail('Export is not available in this process.'); const FX = formatsApi(); + /* Profil (#522): biçim, çözünürlük, kare hızı ve kalite için varsayılan; + açıkça verilen argüman profilin üstüne yazılır. */ + if (args && args.profile != null) { + const prof = FX && FX.profile(args.profile); + if (!prof) return fail('Unknown profile: ' + args.profile); + args = Object.assign({}, prof.set, args); + delete args.profile; + } const fmt = FX ? FX.format((args && args.format) || FX.DEFAULT_FORMAT) : null; if (FX && !fmt) return fail('Unknown format: ' + (args && args.format)); const outBad = outPathError(ctx, args && args.outputPath, [fmt ? fmt.ext : 'mp4'], args && args.overwrite); diff --git a/tests/export-profiles.test.js b/tests/export-profiles.test.js new file mode 100644 index 0000000..e513cef --- /dev/null +++ b/tests/export-profiles.test.js @@ -0,0 +1,96 @@ +'use strict'; +/* DIŞA AKTARMA PROFİLLERİ (#522). + * + * Bir profil biçimi, çözünürlüğü, kare hızını ve kaliteyi sabitliyor. + * Ayrı bir alanı yok: panel seçimi ayarların kendisinden türetiyor, bu yüzden + * bir alan elle değişince profil kendiliğinden "Özel"e dönü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'); + +test('her profil geçerli değerlerden oluşuyor', () => { + const ids = new Set(); + for (const p of F.PROFILES) { + assert.ok(!ids.has(p.id), 'tekrarlı kimlik ' + p.id); + ids.add(p.id); + const fmt = F.format(p.set.format); + assert.ok(fmt, p.id + ' biçimi'); + assert.ok(F.RESOLUTIONS[p.set.resolution], p.id + ' çözünürlüğü'); + assert.ok(F.FPS.includes(p.set.fps), p.id + ' kare hızı'); + assert.ok(!fmt.fpsMax || p.set.fps <= fmt.fpsMax, p.id + ' biçimin sınırını aşmıyor'); + assert.ok(F.QUALITIES.includes(p.set.quality), p.id + ' kalitesi'); + } + // Konudaki dört profil var + for (const id of ['youtube-1080p60', 'reels-shorts', 'twitch-overlay', 'master-4k']) assert.ok(F.profile(id), id); + assert.strictEqual(F.profile('twitch-overlay').set.format, 'webm-vp9-alpha', 'bindirme alfalı'); + assert.deepStrictEqual(F.RESOLUTIONS[F.profile('reels-shorts').set.resolution], [1080, 1920]); +}); + +test('profil ayarlardan türüyor; bir alan değişince Özel', () => { + const base = { format: 'mp4-hevc', resolution: '720p', fps: 25, quality: 'balanced', encoder: 'gpu', speed: 'fast' }; + assert.strictEqual(F.matchProfile(base), 'custom'); + const yt = F.applyProfile(base, 'youtube-1080p60'); + assert.strictEqual(F.matchProfile(yt), 'youtube-1080p60'); + assert.strictEqual(yt.encoder, 'gpu', 'kodlayıcı tercihi korunur'); + assert.strictEqual(yt.speed, 'fast', 'hız dengesi korunur'); + assert.strictEqual(F.matchProfile(Object.assign({}, yt, { fps: 30 })), 'custom'); + assert.deepStrictEqual(F.applyProfile(base, 'yok'), base); + // Kayıttan gelen sayı dize olsa da eşleşir + assert.strictEqual(F.matchProfile(Object.assign({}, yt, { fps: '60' })), 'youtube-1080p60'); +}); + +test('Türkçe profil adlarının İngilizcesi sözlükte', () => { + const src = read('src/shared/i18n.js'); + for (const p of F.PROFILES) { + if (p.label[0] === p.label[1]) continue; + assert.ok(src.includes("'" + p.label[0] + "': '" + p.label[1] + "'"), p.id); + } + assert.ok(src.includes("'Hazır Profil': 'Preset Profile'")); +}); + +test('MCP: profil varsayılan, açık argüman üstte, bilinmeyen reddedilir', async () => { + const mcp = require('../src/shared/mcp.js'); + let got = null; + const cfg = { mcp: { enabled: true, mode: 'write' } }; + const ctx = { locale: () => 'en', getConfig: () => cfg, startExport: (a) => { got = a; return { ok: true }; } }; + const call = (a) => mcp.callTool('sv_start_export', a, ctx); + let r = await call({ audioPath: 'C:/a.wav', outputPath: 'C:/v/x.mp4', profile: 'reels-shorts' }); + assert.strictEqual(r.ok, true, JSON.stringify(r)); + assert.strictEqual(got.resolution, '1080x1920'); + assert.strictEqual(got.format, 'mp4-h264'); + assert.ok(!('profile' in got), 'profil ana sürece gitmiyor'); + r = await call({ audioPath: 'C:/a.wav', outputPath: 'C:/v/x.mp4', profile: 'reels-shorts', fps: 60 }); + assert.strictEqual(got.fps, 60, 'açık argüman kazanır'); + // Profilin biçimi uzantıyı belirliyor + r = await call({ audioPath: 'C:/a.wav', outputPath: 'C:/v/x.mp4', profile: 'master-4k' }); + assert.strictEqual(r.ok, false, 'ProRes profili .mp4 yolunu kabul etmez'); + r = await call({ audioPath: 'C:/a.wav', outputPath: 'C:/v/x.mp4', profile: 'yok' }); + assert.strictEqual(r.ok, false); + const p = mcp.tools('en').find((t) => t.name === 'sv_start_export').inputSchema.properties; + assert.deepStrictEqual(p.profile.enum, F.PROFILES.map((x) => x.id)); +}); + +test('panel: profil seçimi ayardan türüyor, alanları dolduruyor', () => { + const A = read('src/admin/admin.js'); + assert.match(A, /value: \(\) => \(window\.SVExportFormats \? window\.SVExportFormats\.matchProfile\(cfg\.export\) : 'custom'\)/); + assert.match(A, /cfg\.export = FX\.applyProfile\(cfg\.export, id\);/); + // Genel seçim kutusu kancaları + assert.match(A, /const cur = typeof def\.value === 'function' \? def\.value\(\) : getPath\(cfg, def\.path\);/); + assert.match(A, /if \(typeof def\.onChange === 'function'\) def\.onChange\(v\);\s*else setPath\(cfg, def\.path, v\);/); +}); + +/* Panelde ölçüldü: kare hızı elle değişince profil kutusu eski profili + göstermeye devam ediyordu — ayar doğruydu, kart yeniden çizilmiyordu. */ +test('profili etkileyen her alan kartı yeniden çiziyor', () => { + const A = read('src/admin/admin.js'); + for (const p of ['export.format', 'export.resolution', 'export.fps', 'export.quality']) { + const i = A.indexOf("path: '" + p + "'"); + assert.ok(i > 0, p); + assert.match(A.slice(i, A.indexOf('\n', i)), /rebuild: true/, p); + } +}); From 7e9b1ed43c9f62ff00c10b609808730137f613bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Fri, 16 Oct 2026 03:41:43 +0300 Subject: [PATCH 2/2] docs: preset profiles, test count (#522) Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ README.md | 4 ++-- README.tr.md | 4 ++-- ROADMAP.md | 12 ++++++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbac21e..3fee5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ 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. +- **One-click profiles [522].** YouTube 1080p60, YouTube 4K60 (H.265), Reels / Shorts / TikTok + 1080×1920, Instagram square, a Twitch / OBS overlay in WebM with alpha, a ProRes 422 HQ 4K + master and a 720p GIF loop. A profile sets the format, size, frame rate and quality; the GPU or + 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. - **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 f2c7c96..0aef627 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-3132%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3138%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -1715,7 +1715,7 @@ npm test npm start -- --smoke ``` -**3132 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3138 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 baed443..b065a39 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-3132%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3138%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -1718,7 +1718,7 @@ npm test npm start -- --smoke ``` -**3132 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3138 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 d8796b1..c43d321 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) done; profiles, 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) done; 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 ``` -- **3132 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3138 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,14 @@ 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. +### 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. +- **What a profile sets.** Each one fixes format, resolution, frame rate and quality. Colour is BT.709 for every format already. The encoder (GPU/CPU) and the speed balance remain the user's choice. +- **No stored field.** The panel derives the profile from the current settings (`matchProfile`). Choosing one fills the fields, and changing any of them by hand shows "Custom". In the panel, changing the frame rate first left the old profile showing, because only the format select rebuilt the card. Resolution, frame rate and quality now rebuild it too. +- **Availability.** Only profiles whose format works on this machine are listed. +- **MCP.** `profile` supplies defaults, explicit arguments override them, and the profile's format decides the allowed extension. + ### 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.