diff --git a/src/admin/admin.css b/src/admin/admin.css
index d871e56..d79737b 100644
--- a/src/admin/admin.css
+++ b/src/admin/admin.css
@@ -182,6 +182,42 @@ input[type=color]::-webkit-color-swatch { border: none; border-radius: 6px; }
.subctrls { padding-left: 2px; border-left: 2px solid var(--line); padding-left: 12px; margin-top: 6px; }
.hidden { display: none !important; }
+/* ---------- Kullanıcı renk şablonları ---------- */
+.user-presets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.up-empty { font-size: 11.5px; color: var(--muted); line-height: 1.5; padding: 6px 2px; }
+.up-item {
+ display: flex; align-items: stretch; gap: 10px; padding: 7px;
+ border-radius: 10px; background: var(--card2); border: 1px solid var(--line);
+}
+.up-swatch { width: 44px; border-radius: 7px; cursor: pointer; border: 1px solid var(--line); flex-shrink: 0; }
+.up-swatch:hover { border-color: var(--accent); }
+.up-main { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.up-name {
+ background: var(--card); color: var(--text); border: 1px solid var(--line);
+ border-radius: 7px; padding: 5px 8px; font-size: 12.5px; outline: none; width: 100%;
+}
+.up-name:focus { border-color: var(--accent); }
+.up-actions { display: flex; gap: 6px; flex-wrap: wrap; }
+.up-actions .btn { padding: 5px 9px; font-size: 11px; }
+.up-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
+.btn.danger { color: #ff8a8a; border-color: rgba(255,80,80,.35); }
+.btn.danger:hover { background: rgba(255,60,60,.12); }
+
+/* ---------- Ek görsel nesneler ---------- */
+.img-card {
+ border: 1px solid var(--line); border-radius: 12px; background: var(--card2);
+ padding: 10px; margin-top: 10px;
+}
+.img-head { display: flex; gap: 10px; align-items: center; }
+.img-thumb {
+ width: 52px; height: 52px; object-fit: contain; border-radius: 9px;
+ background: #0c0a0d url('data:image/svg+xml;utf8,');
+ border: 1px solid var(--line); flex-shrink: 0;
+}
+.img-headmain { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.img-body { margin-top: 10px; padding-top: 8px; border-top: 1px dashed var(--line); }
+.img-body .ctrl { margin-bottom: 10px; }
+
/* ---------- Video dışa aktarma ---------- */
.export-filename {
margin-top: 8px; font-size: 12px; color: var(--muted);
diff --git a/src/admin/admin.js b/src/admin/admin.js
index e2c3293..9b17b3e 100644
--- a/src/admin/admin.js
+++ b/src/admin/admin.js
@@ -89,6 +89,12 @@
return colorsCtrl(def);
case 'presets':
return presetsCtrl(def);
+ case 'userpresets':
+ return userPresetsCtrl(def);
+ case 'bgio':
+ return bgIoCtrl(def);
+ case 'images':
+ return imagesCtrl(def);
case 'logofile':
return logoFileCtrl(def);
case 'xy':
@@ -266,6 +272,129 @@
]);
}
+ // --- Kullanıcı renk şablonları (kaydet/yeniden adlandır/güncelle/sil + içe/dışa) ---
+ function userPresetsCtrl() {
+ if (!Array.isArray(cfg.userPresets)) cfg.userPresets = [];
+ const wrap = el('div', { class: 'ctrl' });
+ wrap.appendChild(el('label', { class: 'lbl', text: 'Kendi Şablonlarım' }));
+
+ const list = el('div', { class: 'user-presets' });
+ if (!cfg.userPresets.length) {
+ list.appendChild(el('div', { class: 'up-empty', text: 'Henüz şablon yok. Aşağıdaki renkleri ayarlayıp “Mevcut Renkleri Kaydet”e basın.' }));
+ }
+ cfg.userPresets.forEach((p) => {
+ const swatch = el('div', { class: 'up-swatch' });
+ swatch.style.background = `linear-gradient(90deg, ${(p.colors || []).join(',')})`;
+ swatch.title = 'Uygula';
+ swatch.addEventListener('click', () => actions.applyUserPreset(p.id));
+
+ const nameInput = el('input', {
+ type: 'text', class: 'up-name', value: p.name || 'Şablon',
+ onchange: (e) => { p.name = e.target.value.trim() || 'Şablon'; push(true); },
+ });
+
+ const applyBtn = el('button', { class: 'btn ghost small', text: 'Uygula', onclick: () => actions.applyUserPreset(p.id) });
+ const updateBtn = el('button', { class: 'btn ghost small', text: '⟳ Güncelle', title: 'Mevcut renklerle güncelle', onclick: () => actions.updateUserPreset(p.id) });
+ const delBtn = el('button', { class: 'btn ghost small danger', text: '🗑', title: 'Sil', onclick: () => actions.deleteUserPreset(p.id) });
+
+ const row = el('div', { class: 'up-item' }, [
+ swatch,
+ el('div', { class: 'up-main' }, [nameInput, el('div', { class: 'up-actions' }, [applyBtn, updateBtn, delBtn])]),
+ ]);
+ list.appendChild(row);
+ });
+ wrap.appendChild(list);
+
+ const saveBtn = el('button', { class: 'btn ghost small', text: '💾 Mevcut Renkleri Kaydet', onclick: () => actions.saveCurrentPreset() });
+ const expBtn = el('button', { class: 'btn ghost small', text: '📤 Dışa Aktar', onclick: () => actions.exportPresets() });
+ const impBtn = el('button', { class: 'btn ghost small', text: '📥 İçe Aktar', onclick: () => actions.importPresets() });
+ const bar = el('div', { class: 'up-toolbar' }, [saveBtn, expBtn, impBtn]);
+ wrap.appendChild(bar);
+ return wrap;
+ }
+
+ // --- Arkaplan ayarlarını içe/dışa aktarma ---
+ function bgIoCtrl() {
+ const expBtn = el('button', { class: 'btn ghost small', text: '📤 Arkaplanı Dışa Aktar', onclick: () => actions.exportBackground() });
+ const impBtn = el('button', { class: 'btn ghost small', text: '📥 Arkaplanı İçe Aktar', onclick: () => actions.importBackground() });
+ return el('div', { class: 'ctrl' }, [
+ el('label', { class: 'lbl', text: 'Arkaplan Ayarları (dosya)' }),
+ el('div', { class: 'up-toolbar' }, [expBtn, impBtn]),
+ ]);
+ }
+
+ // --- Ek görsel nesneler / partiküller yöneticisi ---
+ const MOTION_OPTS = [
+ { value: 'static', label: 'Sabit' },
+ { value: 'float', label: 'Süzülme' },
+ { value: 'orbit', label: 'Yörünge' },
+ { value: 'swirl', label: 'Girdap' },
+ { value: 'scatter', label: 'Saçılma (sese)' },
+ { value: 'rise', label: 'Yükselme' },
+ { value: 'fall', label: 'Düşme' },
+ ];
+ const BLEND_OPTS = [
+ { value: 'normal', label: 'Normal' },
+ { value: 'screen', label: 'Ekran (parlak)' },
+ { value: 'add', label: 'Toplama (ışıltı)' },
+ ];
+ const LAYER_OPTS = [
+ { value: 'front', label: 'Önde' },
+ { value: 'back', label: 'Arkada' },
+ ];
+
+ function imagesCtrl() {
+ if (!cfg.images) cfg.images = { enabled: false, items: [] };
+ if (!Array.isArray(cfg.images.items)) cfg.images.items = [];
+ const wrap = el('div', {});
+
+ const items = cfg.images.items;
+ if (!items.length) {
+ wrap.appendChild(el('div', { class: 'up-empty', text: 'Görsel eklemek için aşağıdaki düğmeyi kullanın. Her görsel için çok sayıda kopya (partikül) sahnede gezinir/saçılır.' }));
+ }
+
+ items.forEach((it, idx) => {
+ const base = 'images.items.' + idx + '.';
+ const thumb = el('img', { class: 'img-thumb' });
+ if (it.src) thumb.src = it.src;
+
+ const nameInput = el('input', {
+ type: 'text', class: 'up-name', value: it.name || 'Görsel',
+ onchange: (e) => { it.name = e.target.value.trim() || 'Görsel'; push(true); },
+ });
+ const delBtn = el('button', { class: 'btn ghost small danger', text: '🗑 Kaldır', onclick: () => actions.removeImage(it.id) });
+ const replaceBtn = el('button', { class: 'btn ghost small', text: '🖼 Değiştir', onclick: () => actions.replaceImage(it.id) });
+
+ const head = el('div', { class: 'img-head' }, [
+ thumb,
+ el('div', { class: 'img-headmain' }, [nameInput, el('div', { class: 'up-actions' }, [replaceBtn, delBtn])]),
+ ]);
+
+ const body = el('div', { class: 'img-body' });
+ const add = (c) => { const e = buildControl(c); if (e) body.appendChild(e); };
+ add({ type: 'select', path: base + 'motion', label: 'Hareket', options: MOTION_OPTS, rebuild: false });
+ add({ type: 'slider', path: base + 'count', label: 'Kopya Sayısı', min: 1, max: 200, step: 1 });
+ add({ type: 'slider', path: base + 'size', label: 'Boyut', min: 0.01, max: 0.4, step: 0.005, percent: true });
+ add({ type: 'slider', path: base + 'sizeVar', label: 'Boyut Çeşitliliği', min: 0, max: 0.95, step: 0.05, percent: true });
+ add({ type: 'slider', path: base + 'opacity', label: 'Saydamlık', min: 0, max: 1, step: 0.02, percent: true });
+ add({ type: 'slider', path: base + 'spread', label: 'Yayılma / Alan', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'speed', label: 'Hız', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'spin', label: 'Dönüş', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'audioSize', label: 'Ses → Boyut', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'audioSpeed', label: 'Ses → Hız', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'audioOpacity', label: 'Ses → Saydamlık', min: 0, max: 2, step: 0.05 });
+ add({ type: 'slider', path: base + 'glow', label: 'Parlama', min: 0, max: 1, step: 0.02, percent: true });
+ add({ type: 'select', path: base + 'blend', label: 'Karışım', options: BLEND_OPTS });
+ add({ type: 'select', path: base + 'layer', label: 'Katman', options: LAYER_OPTS });
+
+ wrap.appendChild(el('div', { class: 'img-card' }, [head, body]));
+ });
+
+ const addBtn = el('button', { class: 'btn ghost small', text: '➕ Görsel Ekle', onclick: () => actions.addImage() });
+ wrap.appendChild(el('div', { class: 'up-toolbar' }, [addBtn]));
+ return el('div', { class: 'ctrl' }, [wrap]);
+ }
+
function logoFileCtrl() {
const fileInput = el('input', { type: 'file', accept: 'image/*' });
fileInput.style.display = 'none';
@@ -465,6 +594,8 @@
},
{ type: 'colors', path: 'background.gradient.colors', label: 'Renkler (5 nokta)', show: () => cfg.background.type === 'gradient' },
{ type: 'presets', show: () => cfg.background.type === 'gradient' },
+ { type: 'userpresets', show: () => cfg.background.type === 'gradient' },
+ { type: 'bgio' },
{ type: 'slider', path: 'background.gradient.speed', label: 'Akış Hızı', min: 0, max: 2, step: 0.02, show: () => cfg.background.type === 'gradient' },
{ type: 'slider', path: 'background.gradient.drift', label: 'Tek Yönlü Kayma', min: 0, max: 1, step: 0.01, percent: true, show: () => cfg.background.type === 'gradient' },
{ type: 'slider', path: 'background.gradient.wander', label: 'Gezinme Alanı', min: 0, max: 2, step: 0.02, show: () => cfg.background.type === 'gradient' },
@@ -529,6 +660,15 @@
{ type: 'xy', show: () => cfg.logo.enabled },
],
},
+ {
+ icon: '✨',
+ title: 'Görsel Nesneler / Partiküller',
+ desc: 'Bir veya birden fazla resim ekle; sahnede süzülsün, yörünge çizsin, sese göre saçılsın. Boyut, hız, saydamlık, ışıltı ve katman ayarlanır.',
+ controls: [
+ { type: 'toggle', path: 'images.enabled', label: 'Görsel Nesneleri Etkinleştir', rebuild: true },
+ { type: 'images', show: () => cfg.images && cfg.images.enabled },
+ ],
+ },
{
icon: '⚡',
title: 'Güç / Performans',
@@ -588,6 +728,14 @@
{ value: 'balanced', label: 'Dengeli (daha küçük dosya)' },
],
},
+ {
+ type: 'select', path: 'export.speed', label: 'Hız / Kalite Dengesi',
+ options: [
+ { value: 'fast', label: '⚡ Hızlı (en hızlı dışa aktarım)' },
+ { value: 'balanced', label: 'Dengeli (önerilen)' },
+ { value: 'quality', label: 'Kalite (en yavaş, en iyi sıkıştırma)' },
+ ],
+ },
{ type: 'exportpanel' },
],
},
@@ -664,6 +812,135 @@
render();
};
+ // --------------------------------------------------------------------------
+ // Kullanıcı renk şablonları
+ // --------------------------------------------------------------------------
+ function uid(prefix) {
+ return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ }
+ function ensurePresets() {
+ if (!Array.isArray(cfg.userPresets)) cfg.userPresets = [];
+ return cfg.userPresets;
+ }
+
+ actions.saveCurrentPreset = () => {
+ const arr = ensurePresets();
+ const colors = (cfg.background.gradient.colors || []).slice(0, 5);
+ while (colors.length < 5) colors.push(colors[colors.length - 1] || '#000000');
+ arr.push({ id: uid('up_'), name: 'Şablonum ' + (arr.length + 1), colors });
+ push(true);
+ render();
+ };
+ actions.applyUserPreset = (id) => {
+ const p = ensurePresets().find((x) => x.id === id);
+ if (!p) return;
+ cfg.background.gradient.colors = (p.colors || []).slice();
+ push(true);
+ render();
+ };
+ actions.updateUserPreset = (id) => {
+ const p = ensurePresets().find((x) => x.id === id);
+ if (!p) return;
+ p.colors = (cfg.background.gradient.colors || []).slice(0, 5);
+ push(true);
+ render();
+ };
+ actions.deleteUserPreset = (id) => {
+ if (!confirm('Bu şablon silinsin mi?')) return;
+ cfg.userPresets = ensurePresets().filter((x) => x.id !== id);
+ push(true);
+ render();
+ };
+ actions.exportPresets = async () => {
+ const arr = ensurePresets();
+ if (!arr.length) { alert('Dışa aktarılacak şablon yok.'); return; }
+ await window.api.exportJson('renk-sablonlari.json', { type: 'sv-presets', version: 1, presets: arr });
+ };
+ actions.importPresets = async () => {
+ const r = await window.api.importJson('Renk Şablonlarını İçe Aktar');
+ if (!r || !r.ok) { if (r && r.error) alert('İçe aktarılamadı: ' + r.error); return; }
+ const incoming = Array.isArray(r.data) ? r.data : (r.data && r.data.presets) || [];
+ if (!Array.isArray(incoming) || !incoming.length) { alert('Dosyada şablon bulunamadı.'); return; }
+ const arr = ensurePresets();
+ incoming.forEach((p) => {
+ let colors = Array.isArray(p.colors) ? p.colors.slice(0, 5) : [];
+ if (!colors.length) return;
+ while (colors.length < 5) colors.push(colors[colors.length - 1]);
+ arr.push({ id: uid('up_'), name: (p.name || 'İçe Aktarılan').toString(), colors });
+ });
+ push(true);
+ render();
+ };
+
+ // --------------------------------------------------------------------------
+ // Arkaplan ayarları içe/dışa aktarma
+ // --------------------------------------------------------------------------
+ actions.exportBackground = async () => {
+ await window.api.exportJson('arkaplan-ayarlari.json', { type: 'sv-background', version: 1, background: cfg.background });
+ };
+ actions.importBackground = async () => {
+ const r = await window.api.importJson('Arkaplan Ayarlarını İçe Aktar');
+ if (!r || !r.ok) { if (r && r.error) alert('İçe aktarılamadı: ' + r.error); return; }
+ const bg = r.data && (r.data.background || (r.data.type && r.data.gradient ? r.data : null));
+ if (!bg) { alert('Geçerli bir arkaplan dosyası değil.'); return; }
+ const def = window.SV.defaultConfig().background;
+ cfg.background = window.SV.deepMerge(def, bg);
+ push(true);
+ render();
+ };
+
+ // --------------------------------------------------------------------------
+ // Ek görsel nesneler
+ // --------------------------------------------------------------------------
+ function pickImageFile(cb) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.style.display = 'none';
+ input.addEventListener('change', () => {
+ const file = input.files && input.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => cb(reader.result);
+ reader.readAsDataURL(file);
+ });
+ document.body.appendChild(input);
+ input.click();
+ setTimeout(() => input.remove(), 1000);
+ }
+ function ensureImages() {
+ if (!cfg.images) cfg.images = { enabled: false, items: [] };
+ if (!Array.isArray(cfg.images.items)) cfg.images.items = [];
+ return cfg.images;
+ }
+
+ actions.addImage = () => {
+ pickImageFile((dataUrl) => {
+ const imgs = ensureImages();
+ const item = window.SV.imageItem({ src: dataUrl, name: 'Görsel ' + (imgs.items.length + 1) });
+ imgs.items.push(item);
+ imgs.enabled = true;
+ push(true);
+ render();
+ });
+ };
+ actions.replaceImage = (id) => {
+ pickImageFile((dataUrl) => {
+ const imgs = ensureImages();
+ const it = imgs.items.find((x) => x.id === id);
+ if (!it) return;
+ it.src = dataUrl;
+ push(true);
+ render();
+ });
+ };
+ actions.removeImage = (id) => {
+ const imgs = ensureImages();
+ imgs.items = imgs.items.filter((x) => x.id !== id);
+ push(true);
+ render();
+ };
+
// --------------------------------------------------------------------------
// Video dışa aktarma
// --------------------------------------------------------------------------
@@ -706,6 +983,7 @@
fps: cfg.export.fps,
quality: cfg.export.quality,
encoder: cfg.export.encoder,
+ speed: cfg.export.speed,
});
if (!r || !r.ok) {
setExportStatus('⚠ ' + ((r && r.error) || 'Başlatılamadı'), 'err');
@@ -720,6 +998,12 @@
const saved = await window.api.getSettings();
if (saved) cfg = window.SV.deepMerge(window.SV.defaultConfig(), saved);
+ // Eski/eksik görsel nesneleri varsayılan alanlarla tamamla
+ if (cfg.images && Array.isArray(cfg.images.items)) {
+ cfg.images.items = cfg.images.items.map((it) => window.SV.normalizeImageItem(it));
+ }
+ if (!Array.isArray(cfg.userPresets)) cfg.userPresets = [];
+
displays = await window.api.getDisplays();
audioDevices = (await window.api.getOutputDevices()) || [];
diff --git a/src/exporter/exporter.js b/src/exporter/exporter.js
index 6c0bd0c..b8d9513 100644
--- a/src/exporter/exporter.js
+++ b/src/exporter/exporter.js
@@ -63,6 +63,7 @@
let cfg = null;
let gradient = null;
let foreground = null;
+ let sprites = null;
let logo = null;
let pcm = null;
@@ -164,6 +165,13 @@
if (foreground.resize) foreground.resize();
}
+ // Ek görsel nesneler / partiküller (resimler kare 0'dan önce yüklenir)
+ if (cfg.images && cfg.images.enabled && Array.isArray(cfg.images.items) && cfg.images.items.length) {
+ sprites = new window.SVSprites();
+ sprites.setItems(cfg.images.items);
+ await sprites.whenReady();
+ }
+
audio.applyConfig(cfg.audio);
if (cfg.logo.enabled && cfg.logo.src) {
@@ -211,12 +219,18 @@
compCtx.fillRect(0, 0, width, height);
}
+ // Ek görsel nesneler — arka katman (görselin arkasında)
+ if (sprites && sprites.hasLayer('back')) sprites.draw(compCtx, audio, t, width, height, 'back');
+
// Ön görselleştirici (kendi kanvasını temizleyip çizer)
if (foreground) {
foreground.draw(audio, cfg, t, dt);
compCtx.drawImage(c2d, 0, 0, width, height);
}
+ // Ek görsel nesneler — ön katman (görselin önünde)
+ if (sprites && sprites.hasLayer('front')) sprites.draw(compCtx, audio, t, width, height, 'front');
+
// Logo
drawLogo();
@@ -229,17 +243,35 @@
totalFrames = Math.max(1, Math.ceil(duration * fps));
window.exp.ready(totalFrames);
+ // Boru hattı (pipeline): render, IPC ve ffmpeg kodlaması ÜST ÜSTE çalışsın.
+ // Karelerin tek tek beklenmesi yerine WINDOW kadar kareyi "uçuşta" tutarız;
+ // böylece render ederken ffmpeg de önceki kareleri kodlar. Bu, GPU'da
+ // dışa aktarımı belirgin biçimde hızlandırır (darboğaz = max(render, encode)).
+ // WINDOW, bellek için kare boyutuna göre sınırlanır (~64 MB tampon).
+ const frameBytes = width * height * 4;
+ const WINDOW = Math.max(3, Math.min(12, Math.round((64 * 1024 * 1024) / frameBytes)));
+
const progStep = Math.max(1, Math.round(fps / 4));
- for (let i = 0; i < totalFrames; i++) {
+ let cancelled = false;
+ const pending = [];
+
+ for (let i = 0; i < totalFrames && !cancelled; i++) {
const data = renderFrame(i);
- // Geri basınç: ana süreç kareyi ffmpeg.stdin'e yazana kadar bekle.
- const res = await window.exp.sendFrame(data, i);
- if (res && res.cancel) {
- window.exp.cancelled();
- return;
- }
+ // Geri basınç: ana süreç kareyi ffmpeg.stdin'e yazana kadar bekler; ama
+ // hemen await etmeyip pencereyi dolduruyoruz (render/encode örtüşmesi).
+ const p = window.exp.sendFrame(data, i).then((res) => {
+ if (res && res.cancel) cancelled = true;
+ });
+ pending.push(p);
+ if (pending.length >= WINDOW) await pending.shift();
if (i % progStep === 0) window.exp.progress(i + 1, totalFrames);
}
+
+ await Promise.all(pending);
+ if (cancelled) {
+ window.exp.cancelled();
+ return;
+ }
window.exp.progress(totalFrames, totalFrames);
window.exp.finish();
}
diff --git a/src/exporter/index.html b/src/exporter/index.html
index 18f94e4..7cf5d54 100644
--- a/src/exporter/index.html
+++ b/src/exporter/index.html
@@ -25,6 +25,7 @@
+