Skip to content

Commit e515197

Browse files
committed
feat(v3.1.1): Spout and Syphon output over the GPU
The picture now goes straight to another application on the same machine — Resolume, OBS, TouchDesigner, any Spout or Syphon receiver — with no window capture, no plugin and no CPU copy. A hidden offscreen window renders the visualizer and Electron hands over the GPU texture on every paint. Verified end to end on Windows rather than assumed: the sender appears in Spout's own registry under its configured name, delivers 30 frames a second with none dropped at the configured rate, and disappears from the registry when stopped. The self-test asserts all of that, including the frame count — a sender that registers but never delivers would look perfectly healthy from the outside while the receiver shows a frozen image. Two things the documentation got wrong, both of which fail silently: - texture-bridge types the handle as a number. Electron 43 gives { ntHandle: Buffer } on Windows and { ioSurface: Buffer } on macOS, and the two platforms need different sender calls. Wrong extraction produces no error at all, just a black frame at the receiver. That conversion is a pure module with its own tests, which is the only way macOS gets any coverage here. - A handle beyond the safe integer range is rejected rather than silently rounded, since a rounded handle names a different object. The texture is released on every path, including failures. A missed release leaks a GPU resource per frame. Audio capture now stays alive when this output is the only consumer. Otherwise a receiver would get a picture that never reacts, with no visualizer window open to explain why. Linux gets a card that explains why the feature is absent and points at the OBS browser source, rather than no card at all. Unlike Dynamic Lighting there is somewhere useful to send people. Also fixes a bug this work introduced and the self-test caught: an unescaped apostrophe in an English translation stopped i18n.js parsing in the browser, so the entire interface stayed untranslated — reported as 698 untranslated strings when the cause was one character. Every source file is now parsed by the unit suite, which turns a CI round trip into a few milliseconds locally. 808 unit tests, smoke PASS.
1 parent 8972de8 commit e515197

12 files changed

Lines changed: 800 additions & 1 deletion

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ hands, and a row only moves up once it ships.
8080
| macOS build | v3.1.1 | Built in CI and verified structurally — Apple Silicon only, unsigned, and system audio needs a virtual device such as BlackHole. Not yet run on a Mac |
8181
| Linux build | v3.1.1 | AppImage and deb built in CI. The audio engine loads there; device enumeration is still unverified on a real desktop |
8282
| OpenRGB | v3.1.1 | Works and is covered by tests against a protocol-level server. Not yet driven against real hardware |
83-
| Spout / Syphon | v3.1.1 | Not started. Windows and macOS only — Linux has no equivalent and will not be offered a switch that does nothing |
83+
| Spout / Syphon | v3.1.1 | Sending, and verified end to end on Windows: the sender registers with Spout, 30 frames a second with none dropped, and deregisters on stop. Syphon shares the same code path but has never been run on a Mac. Not offered on Linux |
8484
| Per-app audio capture | v3.1.2 | Not started. Needs a native module on both Windows and macOS; the same API family gives macOS its missing system-audio loopback |
8585
| Comprehensive video export | v3.1.3 | Not started |
8686
| Broadcast layout editor | v3.1.4 | Not started |

src/admin/admin.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,8 @@
444444
return window.SVScenePanels ? window.SVScenePanels.artnetPanel() : null;
445445
case 'openrgbpanel':
446446
return window.SVOpenRGBPanel ? window.SVOpenRGBPanel.panel() : null;
447+
case 'texturepanel':
448+
return window.SVTexturePanel ? window.SVTexturePanel.panel() : null;
447449
case 'autovjpanel':
448450
return window.SVAutoVJ ? window.SVAutoVJ.panel() : null;
449451
case 'timelinepanel':
@@ -2105,6 +2107,19 @@
21052107
desc: 'Ayarlarınızda açık görünüyor ama Windows Dynamic Lighting yalnızca Windows üzerinde çalışır. Bu sistemde RGB aygıtları için OpenRGB veya Art-Net/DMX kullanın.',
21062108
controls: [],
21072109
},
2110+
{
2111+
/* Spout (Windows) / Syphon (macOS): goruntuyu ayni makinedeki
2112+
baska bir uygulamaya GPU uzerinden verir. Linux'ta yok, ama
2113+
kart yine cizilir: orada NEDEN olmadigini ve yerine ne
2114+
kullanilacagini soylemek, olmayan bir ayari aratmaktan iyidir. */
2115+
id: 'textureShare',
2116+
category: 'output',
2117+
icon: '🎬',
2118+
wide: true,
2119+
title: 'Spout / Syphon Çıkışı',
2120+
desc: 'Görüntüyü aynı bilgisayardaki başka bir uygulamaya GPU üzerinden verir: Resolume, OBS, TouchDesigner. Pencere yakalama ve eklenti gerekmez.',
2121+
controls: [{ type: 'texturepanel' }],
2122+
},
21082123
{
21092124
/* Windows'ta Dynamic Lighting'in YERINE degil YANINA: LampArray
21102125
yalniz Windows'un tanidigi aygitlari surer, OpenRGB cok daha

src/admin/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ <h2 id="settingsTitle">Ayarlar</h2>
284284
<script src="clipdeck-panel.js"></script>
285285
<script src="../shared/lighting-render.js"></script>
286286
<script src="openrgb-panel.js"></script>
287+
<script src="texture-panel.js"></script>
287288
<script src="perform-view.js"></script>
288289
<script src="analysis-panel.js"></script>
289290
<script src="transition-panel.js"></script>

src/admin/texture-panel.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
'use strict';
2+
/* Spout / Syphon paneli.
3+
*
4+
* Protokolün adı platforma göre değişiyor (Windows'ta Spout, macOS'ta
5+
* Syphon) ve kullanıcı hangisini aradığını bilmeli: alıcı uygulamada
6+
* arayacağı menü öğesinin adı bu.
7+
*
8+
* Linux'ta özellik YOK. Ama kartı tümden gizlemiyoruz: burada gizlemek
9+
* kullanıcıyı olmayan bir ayarı aramaya bırakır. Onun yerine neden
10+
* olmadığı ve yerine ne kullanılacağı yazılı — Dynamic Lighting'den farkı
11+
* bu, orada gidilecek bir yer yoktu.
12+
*/
13+
(function () {
14+
const P = () => window.SVPanel;
15+
16+
let watching = false;
17+
let state = { running: false, supported: null, protocol: '', frames: 0, dropped: 0, error: null, reason: null };
18+
let senders = [];
19+
20+
function protocolName() {
21+
if (state.protocol === 'syphon') return 'Syphon';
22+
if (state.protocol === 'spout') return 'Spout';
23+
const p = window.SV_PLATFORM || {};
24+
if (p.isMac) return 'Syphon';
25+
if (p.isWindows) return 'Spout';
26+
return 'Spout / Syphon';
27+
}
28+
29+
function panel() {
30+
const el = P().el;
31+
const cfg = P().cfg();
32+
const t = cfg.textureShare;
33+
const nodes = [];
34+
35+
if (!watching) {
36+
watching = true;
37+
window.api.onTextureStatus((s) => { state = s; });
38+
window.api.textureStatus().then((s) => { state = s; }).catch(() => {});
39+
}
40+
41+
/* Bu platformda hiç yoksa: sebebini ve alternatifini söyle, bitir. */
42+
if (state.supported === false) {
43+
nodes.push(el('div', {
44+
class: 'settings-io-note',
45+
text: 'Spout bir Windows, Syphon bir macOS teknolojisidir; bu sistemde ikisi de yok ve yerleşik bir eşdeğeri bulunmuyor. Görüntüyü başka bir uygulamaya vermek için Çıkış bölümündeki OBS tarayıcı kaynağını kullanın — o her platformda çalışır.',
46+
}));
47+
return el('div', { class: 'texture-panel' }, nodes);
48+
}
49+
50+
const enable = el('input', {
51+
type: 'checkbox',
52+
onchange: async (e) => {
53+
t.enabled = e.target.checked;
54+
P().push(true);
55+
try { state = await window.api.textureSync(); } catch {}
56+
P().rerender();
57+
},
58+
});
59+
enable.checked = !!t.enabled;
60+
nodes.push(P().row(protocolName() + ' Çıkışı', el('label', { class: 'switch' }, [enable, el('span', { class: 'track' })])));
61+
62+
// --- Durum. Sayılar ayrı düğümlerde: birleşik metin çevrilemez. ---
63+
const st = el('div', { class: 'studio-status ' + (state.running && !state.error ? 'ok' : state.error ? 'err' : '') });
64+
if (state.running && !state.error) {
65+
st.appendChild(el('span', { text: '✓ ' }));
66+
st.appendChild(el('span', { text: 'Yayında' }));
67+
st.appendChild(el('span', { class: 'st-sep', text: ' · ' }));
68+
st.appendChild(el('span', { class: 'st-num', text: state.width + '×' + state.height }));
69+
st.appendChild(el('span', { class: 'st-sep', text: ' · ' }));
70+
st.appendChild(el('span', { class: 'st-num', text: String(state.frames || 0) + ' ' }));
71+
st.appendChild(el('span', { text: 'kare' }));
72+
if (state.dropped) {
73+
st.appendChild(el('span', { class: 'st-sep', text: ' · ' }));
74+
st.appendChild(el('span', { class: 'st-num', text: String(state.dropped) + ' ' }));
75+
st.appendChild(el('span', { text: 'düşen' }));
76+
}
77+
} else if (state.error) {
78+
st.appendChild(el('span', { text: '✕ ' }));
79+
st.appendChild(el('span', { text: 'Hata' }));
80+
} else {
81+
st.appendChild(el('span', { text: 'Kapalı' }));
82+
}
83+
nodes.push(st);
84+
85+
if (!t.enabled) {
86+
nodes.push(el('div', {
87+
class: 'settings-io-note',
88+
text: 'Açıldığında görüntü, aynı bilgisayardaki alıcı uygulamalara GPU üzerinden verilir: Resolume, OBS, TouchDesigner veya başka bir alıcı. Pencere yakalamaya, eklenti kurmaya ve CPU kopyasına gerek yok.',
89+
}));
90+
return el('div', { class: 'texture-panel' }, nodes);
91+
}
92+
93+
/* Kaynak adı: alıcı uygulamada BU ad görünecek. */
94+
nodes.push(P().row('Kaynak Adı', el('input', {
95+
type: 'text',
96+
value: t.name || 'CAYADEV Visualizer',
97+
onchange: (e) => {
98+
t.name = e.target.value.trim() || 'CAYADEV Visualizer';
99+
P().push(true);
100+
window.api.textureSync().catch(() => {});
101+
},
102+
})));
103+
104+
const sizes = [[1280, 720], [1920, 1080], [2560, 1440], [3840, 2160]];
105+
const sizeSel = el('select', {
106+
onchange: (e) => {
107+
const [w, h] = e.target.value.split('x').map(Number);
108+
t.width = w;
109+
t.height = h;
110+
P().push(true);
111+
window.api.textureSync().catch(() => {});
112+
},
113+
});
114+
for (const [w, h] of sizes) {
115+
const v = w + 'x' + h;
116+
const opt = el('option', { value: v, text: v });
117+
if (Number(t.width) === w && Number(t.height) === h) opt.selected = true;
118+
sizeSel.appendChild(opt);
119+
}
120+
nodes.push(P().row('Çözünürlük', sizeSel));
121+
122+
nodes.push(P().row('Kare Hızı', el('input', {
123+
type: 'range', min: 10, max: 60, step: 1, value: t.fps || 60,
124+
oninput: (e) => { t.fps = Number(e.target.value); P().push(false); },
125+
onchange: () => { P().push(true); window.api.textureSync().catch(() => {}); },
126+
})));
127+
128+
/* Aynı makinedeki kaynaklar: kullanıcının kendi göndericisini alıcı
129+
tarafta göremediğinde ilk sorduğu şey "gerçekten yayında mı?" */
130+
const refresh = el('button', { class: 'btn small', type: 'button', text: 'Kaynakları Listele' });
131+
refresh.addEventListener('click', async () => {
132+
try { senders = await window.api.textureSenders(); } catch { senders = []; }
133+
try { state = await window.api.textureStatus(); } catch {}
134+
P().rerender();
135+
});
136+
nodes.push(P().row('Bu Makinedeki Kaynaklar', refresh));
137+
138+
if (senders.length) {
139+
const list = el('div', { class: 'orgb-devices' });
140+
for (const s of senders) {
141+
const mine = s.name === (t.name || 'CAYADEV Visualizer');
142+
const row = el('div', { class: 'orgb-device' });
143+
row.appendChild(el('span', { class: 'orgb-name', text: s.name }));
144+
if (mine) row.appendChild(el('span', { class: 'orgb-meta', text: 'bu uygulama' }));
145+
list.appendChild(row);
146+
}
147+
nodes.push(list);
148+
}
149+
150+
nodes.push(el('div', {
151+
class: 'settings-io-note',
152+
text: 'Alıcı uygulamada yukarıdaki kaynak adını seçin. Görüntü, ana ekranınızın yapılandırmasıyla üretilir; ekranlarda pencere açık olmasa bile yayın sürer.',
153+
}));
154+
155+
return el('div', { class: 'texture-panel' }, nodes);
156+
}
157+
158+
const api = { panel, protocolName };
159+
if (typeof window !== 'undefined') window.SVTexturePanel = api;
160+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
161+
})();

src/main/main.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const streamServer = require('./stream-server');
1717
const oscServer = require('./osc-server');
1818
const artnet = require('./artnet');
1919
const openrgb = require('./openrgb');
20+
const textureShare = require('./texture-share');
2021
const presetsStore = require('./presets-store');
2122
const mediaUrl = require('../shared/media-url');
2223
const { serveMediaFile } = require('./media-file');
@@ -267,6 +268,11 @@ function meterWindow() {
267268
// Tüm görselleştirme pencerelerine mesaj yolla
268269
function sendToVisualizers(channel, payload) {
269270
for (const win of openWindows()) win.webContents.send(channel, payload);
271+
/* Spout/Syphon penceresi görünmez ve visualizerWins içinde DEĞİL
272+
(orası ekranlara ait). Ama aynı yapılandırmayı ve aynı ses
273+
karelerini alması gerekiyor, yoksa donmuş bir kare yayınlar. */
274+
const ts = textureShare.window();
275+
if (ts) ts.webContents.send(channel, payload);
270276
}
271277

272278
// İstenen ekran kimliklerini çöz (tek sayı, dizi veya boş kabul edilir)
@@ -456,6 +462,9 @@ function captureWanted() {
456462
return (
457463
anyVisualizerOpen() ||
458464
previewSubscribed ||
465+
/* Hiçbir ekranda pencere olmasa bile Spout/Syphon çıkışı sesi
466+
istiyor: aksi halde alıcı sessiz bir görüntü alır. */
467+
!!textureShare.window() ||
459468
// Yalnızca yayın KATMANI ses karesi tüketir; tek başına bağlı bir mobil
460469
// kumanda yakalamayı ayakta tutmamalı (ses aygıtını boşuna meşgul eder).
461470
streamServer.overlayCount() > 0
@@ -770,6 +779,7 @@ ipcMain.on('update-config', (e, config) => {
770779
syncOscServer();
771780
syncArtnet();
772781
syncOpenRgb();
782+
syncTextureShare();
773783
// Ses kaynağı değiştiyse yakalamayı yeniden başlat. Bu, görselleştirici kapalıyken
774784
// yalnızca panel önizlemesi dinliyor olsa da geçerlidir.
775785
syncCapture();
@@ -1176,6 +1186,22 @@ function syncArtnet() {
11761186
});
11771187
}
11781188

1189+
function syncTextureShare() {
1190+
const t = (currentConfig && currentConfig.textureShare) || {};
1191+
if (!t.enabled) {
1192+
return textureShare.stop().then((st) => {
1193+
syncCapture();
1194+
return st;
1195+
});
1196+
}
1197+
return textureShare.start(t).then((st) => {
1198+
notifyAdmin('texture-share-status', st);
1199+
/* Pencere yeni açıldıysa yakalama gerekiyor olabilir. */
1200+
syncCapture();
1201+
return st;
1202+
});
1203+
}
1204+
11791205
function syncOpenRgb() {
11801206
const o = (currentConfig && currentConfig.openrgb) || {};
11811207
if (!o.enabled) return openrgb.stop().then(() => openrgb.status());
@@ -1211,6 +1237,9 @@ ipcMain.handle('artnet:sync', () => syncArtnet());
12111237
ipcMain.handle('openrgb:status', () => openrgb.status());
12121238
ipcMain.handle('openrgb:sync', () => syncOpenRgb());
12131239
ipcMain.handle('openrgb:rescan', () => openrgb.rescan());
1240+
ipcMain.handle('texture:status', () => textureShare.status());
1241+
ipcMain.handle('texture:sync', () => syncTextureShare());
1242+
ipcMain.handle('texture:senders', () => textureShare.listSenders());
12141243

12151244
// ----------------------------------------------------------------------------
12161245
// Medya katmanı: video dosyası seçimi
@@ -1632,6 +1661,7 @@ app.whenReady().then(async () => {
16321661
syncOscServer().catch(() => {});
16331662
syncArtnet().catch(() => {});
16341663
syncOpenRgb().catch(() => {});
1664+
syncTextureShare().catch(() => {});
16351665

16361666
// Ekran değişikliklerini admin'e bildir
16371667
screen.on('display-added', () => notifyAdmin('displays-changed', getDisplayList()));
@@ -2634,6 +2664,10 @@ async function runSmoke() {
26342664
/* OpenRGB de varsayılan kapalı: açılmazsa panelin bütün etiketleri
26352665
DOM'a hiç girmez ve çevrilmemiş metin fark edilmeden çıkar. */
26362666
c.openrgb.enabled = true;
2667+
/* Spout/Syphon paneli de varsayilan kapali. Linux'ta bu anahtar
2668+
hicbir sey yapmaz ama panel yine cizilir (orada NEDEN olmadigini
2669+
anlatan metin var) ve o metnin de cevrilmis olmasi gerekir. */
2670+
if (c.textureShare) c.textureShare.enabled = true;
26372671
window.SVPanel.rerender();
26382672
return 'açıldı';
26392673
})()`);
@@ -2838,6 +2872,63 @@ async function runSmoke() {
28382872
}
28392873
}
28402874

2875+
/* Spout/Syphon paneli ciziliyor mu? Panel dizi dondurse ya da
2876+
yuklenmese kart tumuyle kaybolur ve konsolda tek satir hata kalir. */
2877+
const texPanel = await adminWin.webContents.executeJavaScript(`(function(){
2878+
if (!window.SVTexturePanel || !window.SVPanel) return JSON.stringify({ hata: 'panel yok' });
2879+
var c = window.SVPanel.cfg();
2880+
if (c.textureShare) c.textureShare.enabled = true;
2881+
window.SVPanel.rerender();
2882+
var n = window.SVTexturePanel.panel();
2883+
return JSON.stringify({
2884+
düğümTürü: (n && n.nodeType) || 0,
2885+
çocuk: (n && n.childNodes) ? n.childNodes.length : -1,
2886+
protokol: window.SVTexturePanel.protocolName(),
2887+
});
2888+
})()`);
2889+
console.log('[SMOKE] Spout/Syphon paneli: ' + texPanel);
2890+
{
2891+
const t = JSON.parse(texPanel);
2892+
if (t.hata) errors.push('texture-share: ' + t.hata);
2893+
else {
2894+
if (t.düğümTürü !== 1) errors.push('texture-share: the panel did not return a single element node');
2895+
if (!(t.çocuk > 2)) errors.push('texture-share: the panel drew almost nothing (' + t.çocuk + ' children)');
2896+
if (!/^(Spout|Syphon)/.test(t.protokol)) errors.push('texture-share: protocol name is wrong for this platform: ' + t.protokol);
2897+
}
2898+
}
2899+
2900+
/* Spout/Syphon: gerçekten yayında mı ve kare AKIYOR mu?
2901+
Gönderici kaydı görünüp kare akmazsa alıcı donmuş bir görüntü
2902+
alır ve her şey yolunda sanır — sessizce yanlış olan durum bu. */
2903+
{
2904+
const av = textureShare.available();
2905+
if (!av.ok) {
2906+
console.log('[SMOKE] Spout/Syphon: bu platformda yok (' + av.reason + ')');
2907+
} else {
2908+
currentConfig.textureShare = Object.assign({}, currentConfig.textureShare, {
2909+
enabled: true, name: 'CAYADEV Smoke', width: 640, height: 360, fps: 30,
2910+
});
2911+
await syncTextureShare();
2912+
await wait(3000);
2913+
const ts = textureShare.status();
2914+
const senders = textureShare.listSenders().map((x) => x.name);
2915+
const mine = senders.indexOf('CAYADEV Smoke') >= 0;
2916+
console.log('[SMOKE] Spout/Syphon: ' + JSON.stringify({
2917+
protokol: ts.protocol, kare: ts.frames, düşen: ts.dropped,
2918+
boyut: ts.width + 'x' + ts.height, kayıt: mine, hata: ts.error,
2919+
}));
2920+
if (!ts.running) errors.push('texture-share: did not start');
2921+
if (!mine) errors.push('texture-share: the sender is not registered with ' + ts.protocol);
2922+
if (!(ts.frames > 20)) errors.push('texture-share: only ' + ts.frames + ' frames in 3s — the receiver would see a frozen image');
2923+
if (ts.dropped > ts.frames / 10) errors.push('texture-share: dropped ' + ts.dropped + ' of ' + (ts.frames + ts.dropped) + ' frames');
2924+
const stopped = await textureShare.stop();
2925+
const after = textureShare.listSenders().map((x) => x.name);
2926+
if (after.indexOf('CAYADEV Smoke') >= 0) errors.push('texture-share: the sender stayed registered after stop');
2927+
if (stopped.running) errors.push('texture-share: still running after stop');
2928+
currentConfig.textureShare.enabled = false;
2929+
}
2930+
}
2931+
28412932
/* OpenRGB paneli gerçekten çiziliyor mu ve mod listesini Dynamic
28422933
Lighting ile PAYLAŞIYOR mu? İki ayrı liste olsaydı aynı mod iki
28432934
kartta iki farklı adla görünürdü ve bunu kimse fark etmezdi. */

src/main/preload-admin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ contextBridge.exposeInMainWorld('api', {
2929
openrgbStatus: () => ipcRenderer.invoke('openrgb:status'),
3030
openrgbSync: () => ipcRenderer.invoke('openrgb:sync'),
3131
openrgbRescan: () => ipcRenderer.invoke('openrgb:rescan'),
32+
textureStatus: () => ipcRenderer.invoke('texture:status'),
33+
textureSync: () => ipcRenderer.invoke('texture:sync'),
34+
textureSenders: () => ipcRenderer.invoke('texture:senders'),
35+
onTextureStatus: (cb) => ipcRenderer.on('texture-share-status', (e, d) => cb(d)),
3236
onOpenRgbStatus: (cb) => ipcRenderer.on('openrgb-status', (e, d) => cb(d)),
3337
applyLighting: (lighting) => ipcRenderer.invoke('lighting:apply', lighting),
3438
getLightingIdentityStatus: () => ipcRenderer.invoke('lighting:identity-status'),

0 commit comments

Comments
 (0)