Skip to content

Commit c74faaa

Browse files
authored
Merge pull request #14 from steveseguin/fix/vdo-alpha-popout-buffer-sync
fix: sync VDO alpha popout buffers
2 parents 685fb55 + 7db4c69 commit c74faaa

18 files changed

Lines changed: 458 additions & 42 deletions
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import { MockPluginServer } from './mock-ws-server';
3+
4+
test.use({
5+
permissions: ['camera', 'microphone'],
6+
launchOptions: {
7+
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
8+
},
9+
});
10+
11+
type VdoTarget = {
12+
name: string;
13+
baseUrl: string;
14+
companionPath: string;
15+
};
16+
17+
const VDO_ORIGIN = 'https://vdo.ninja';
18+
const BUFFER_SEQUENCE = [9000, 4000, 13000, 5000, 11000, 7000];
19+
const VDO_TARGETS: VdoTarget[] = [
20+
{ name: 'default-alpha', baseUrl: 'https://vdo.ninja/alpha/', companionPath: '/' },
21+
{ name: 'production-override', baseUrl: 'https://vdo.ninja/', companionPath: '/?vdoProduction=1' },
22+
];
23+
24+
let mock: MockPluginServer;
25+
26+
test.beforeEach(async () => {
27+
mock = new MockPluginServer(7170);
28+
});
29+
30+
test.afterEach(async () => {
31+
await mock.close();
32+
});
33+
34+
function vdoUrl(target: VdoTarget, query: string): string {
35+
return `${target.baseUrl}?${query}`;
36+
}
37+
38+
async function callVdoIframe(page: Page, iframeSelector: string, payload: Record<string, unknown>, timeoutMs = 6000) {
39+
return page.evaluate(
40+
({ iframeSelector, payload, timeoutMs }) =>
41+
new Promise<Record<string, unknown>>((resolve, reject) => {
42+
const iframe = document.querySelector(iframeSelector) as HTMLIFrameElement | null;
43+
if (!iframe?.contentWindow) {
44+
reject(new Error(`VDO iframe not found: ${iframeSelector}`));
45+
return;
46+
}
47+
48+
const cib = `cib-${Date.now()}-${Math.random().toString(16).slice(2)}`;
49+
const timer = window.setTimeout(() => {
50+
window.removeEventListener('message', onMessage);
51+
reject(new Error(`Timed out waiting for VDO callback ${JSON.stringify(payload)}`));
52+
}, timeoutMs);
53+
54+
function onMessage(event: MessageEvent) {
55+
if (event.source !== iframe?.contentWindow) return;
56+
const data = event.data;
57+
if (!data || typeof data !== 'object' || data.cib !== cib) return;
58+
window.clearTimeout(timer);
59+
window.removeEventListener('message', onMessage);
60+
resolve(data as Record<string, unknown>);
61+
}
62+
63+
window.addEventListener('message', onMessage);
64+
iframe.contentWindow.postMessage({ ...payload, cib }, '*');
65+
}),
66+
{ iframeSelector, payload, timeoutMs }
67+
);
68+
}
69+
70+
async function callVdoTop(page: Page, payload: Record<string, unknown>, timeoutMs = 6000) {
71+
return page.evaluate(
72+
({ payload, timeoutMs }) =>
73+
new Promise<Record<string, unknown>>((resolve, reject) => {
74+
const cib = `cib-${Date.now()}-${Math.random().toString(16).slice(2)}`;
75+
const timer = window.setTimeout(() => {
76+
window.removeEventListener('message', onMessage);
77+
reject(new Error(`Timed out waiting for top-level VDO callback ${JSON.stringify(payload)}`));
78+
}, timeoutMs);
79+
80+
function onMessage(event: MessageEvent) {
81+
const data = event.data;
82+
if (!data || typeof data !== 'object' || data.cib !== cib) return;
83+
window.clearTimeout(timer);
84+
window.removeEventListener('message', onMessage);
85+
resolve(data as Record<string, unknown>);
86+
}
87+
88+
window.addEventListener('message', onMessage);
89+
window.postMessage({ ...payload, cib }, '*');
90+
}),
91+
{ payload, timeoutMs }
92+
);
93+
}
94+
95+
async function postBufferDelayToIframe(page: Page, iframeSelector: string, delayMs: number, streamId?: string) {
96+
await page.evaluate(
97+
({ iframeSelector, delayMs, streamId, targetOrigin }) => {
98+
const iframe = document.querySelector(iframeSelector) as HTMLIFrameElement | null;
99+
if (!iframe?.contentWindow) throw new Error(`VDO iframe not found: ${iframeSelector}`);
100+
const payload: { setBufferDelay: number; streamID?: string } = { setBufferDelay: delayMs };
101+
if (streamId) payload.streamID = streamId;
102+
iframe.contentWindow.postMessage(payload, targetOrigin);
103+
},
104+
{ iframeSelector, delayMs, streamId, targetOrigin: VDO_ORIGIN }
105+
);
106+
}
107+
108+
async function startPublisherIfNeeded(page: Page) {
109+
const joinButton = page.getByRole('button', { name: /join room with camera/i });
110+
try {
111+
await joinButton.click({ timeout: 10000 });
112+
await page.waitForTimeout(3000);
113+
} catch {
114+
// Autostart/test media may have already started.
115+
}
116+
}
117+
118+
function logPageDiagnostics(page: Page, label: string) {
119+
page.on('console', msg => {
120+
const text = msg.text();
121+
if (/error|warn|websocket|socket|failed|denied|gum|publish|room|join/i.test(text)) {
122+
console.log(`${label} console ${msg.type()}: ${text.slice(0, 500)}`);
123+
}
124+
});
125+
page.on('requestfailed', request => {
126+
console.log(`${label} requestfailed: ${request.url()} ${request.failure()?.errorText}`);
127+
});
128+
page.on('pageerror', err => {
129+
console.log(`${label} pageerror: ${String(err).slice(0, 500)}`);
130+
});
131+
}
132+
133+
async function readVdoBufferState(page: Page, iframeSelector: string, streamId: string) {
134+
const detailed = await callVdoIframe(page, iframeSelector, { getDetailedState: true });
135+
const stats = await callVdoIframe(page, iframeSelector, { getStats: true });
136+
137+
const detailedState = (detailed.detailedState ?? {}) as Record<string, any>;
138+
const item =
139+
streamId === '*'
140+
? Object.values(detailedState).find((entry: any) => entry && !entry.localStream)
141+
: detailedState[streamId] ?? Object.values(detailedState).find((entry: any) => entry?.streamID === streamId);
142+
const inbound = ((stats.stats as any)?.inbound ?? {}) as Record<string, any>;
143+
const streamStats =
144+
streamId === '*'
145+
? item?.streamID
146+
? inbound[item.streamID]
147+
: undefined
148+
: inbound[streamId] ?? (item?.streamID ? inbound[item.streamID] : undefined);
149+
const chunkStats = streamStats?.chunked_mode_video;
150+
151+
return {
152+
detailedState,
153+
item,
154+
streamStats,
155+
chunkStats,
156+
requested: item?.chunkedBufferRequested,
157+
statsBuffer: chunkStats?.buffer_buffer,
158+
};
159+
}
160+
161+
async function waitForExpectedBuffer(
162+
page: Page,
163+
iframeSelector: string,
164+
streamId: string,
165+
expectedDelayMs: number,
166+
timeoutMs = 60000
167+
) {
168+
const started = Date.now();
169+
let last: Awaited<ReturnType<typeof readVdoBufferState>> | null = null;
170+
while (Date.now() - started < timeoutMs) {
171+
try {
172+
last = await readVdoBufferState(page, iframeSelector, streamId);
173+
if (
174+
last.item &&
175+
last.chunkStats &&
176+
last.requested === expectedDelayMs &&
177+
last.statsBuffer === expectedDelayMs
178+
) {
179+
return last;
180+
}
181+
} catch {
182+
// VDO may still be loading, connecting, or applying the new target.
183+
}
184+
await page.waitForTimeout(1000);
185+
}
186+
throw new Error(`Timed out waiting for stream ${streamId} buffer=${expectedDelayMs}; last=${JSON.stringify(last)}`);
187+
}
188+
189+
async function waitForAnyBuffer(page: Page, iframeSelector: string, streamId: string, timeoutMs = 60000) {
190+
const started = Date.now();
191+
let last: Awaited<ReturnType<typeof readVdoBufferState>> | null = null;
192+
while (Date.now() - started < timeoutMs) {
193+
try {
194+
last = await readVdoBufferState(page, iframeSelector, streamId);
195+
if (last.item && last.chunkStats && typeof last.statsBuffer === 'number') {
196+
return last;
197+
}
198+
} catch {
199+
// VDO may still be loading or connecting.
200+
}
201+
await page.waitForTimeout(1000);
202+
}
203+
throw new Error(`Timed out waiting for stream ${streamId}; last=${JSON.stringify(last)}`);
204+
}
205+
206+
for (const target of VDO_TARGETS) {
207+
test(`control: direct VDO ${target.name} viewer applies and updates buffer`, async ({ page, browser }) => {
208+
test.setTimeout(150000);
209+
210+
const streamId = 'alice';
211+
const expectedDelayMs = BUFFER_SEQUENCE[0];
212+
213+
const publisherContext = await browser.newContext({ permissions: ['camera', 'microphone'] });
214+
const publisher = await publisherContext.newPage();
215+
logPageDiagnostics(publisher, `${target.name} direct publisher`);
216+
await publisher.goto(
217+
vdoUrl(
218+
target,
219+
`push=${streamId}&webcam&autostart&chunked=1800&label=Alice&testmedia=1&testwidth=640&testheight=360&testfps=30`
220+
),
221+
{ waitUntil: 'domcontentloaded', timeout: 45000 }
222+
);
223+
await startPublisherIfNeeded(publisher);
224+
await publisher.waitForTimeout(5000);
225+
console.log(`${target.name} direct publisherUrl`, publisher.url());
226+
const publisherState = await callVdoTop(publisher, { getDetailedState: true }).catch(err => ({ error: String(err) }));
227+
test.info().annotations.push({ type: `${target.name}PublisherState`, description: JSON.stringify(publisherState).slice(0, 1000) });
228+
229+
const viewerUrl = vdoUrl(
230+
target,
231+
`view=${streamId}&scene&cleanoutput&noaudio&chunked&fixedchunkbuffer&chunkbufferadaptive=0&chunkbufferceil=180000&chunkbuffer=${expectedDelayMs}&buffer=${expectedDelayMs}`
232+
);
233+
234+
logPageDiagnostics(page, `${target.name} direct viewer`);
235+
await page.goto(target.companionPath);
236+
await page.setContent(`<iframe id="viewer" src="${viewerUrl}" style="width:100%;height:700px;border:0"></iframe>`);
237+
238+
let observed = await waitForAnyBuffer(page, '#viewer', streamId);
239+
expect(observed.item?.localStream).toBe(false);
240+
241+
for (const delayMs of BUFFER_SEQUENCE) {
242+
await postBufferDelayToIframe(page, '#viewer', delayMs, streamId);
243+
observed = await waitForExpectedBuffer(page, '#viewer', streamId, delayMs, 30000);
244+
expect(observed.item?.localStream).toBe(false);
245+
}
246+
247+
await publisherContext.close();
248+
});
249+
250+
test(`control: VDO ${target.name} room viewer with scene/view applies buffer`, async ({ page, browser }) => {
251+
test.setTimeout(120000);
252+
253+
const expectedDelayMs = BUFFER_SEQUENCE[0];
254+
const room = `jw-room-control-${target.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
255+
const streamId = 'alice';
256+
257+
const publisherContext = await browser.newContext({ permissions: ['camera', 'microphone'] });
258+
const publisher = await publisherContext.newPage();
259+
logPageDiagnostics(publisher, `${target.name} room publisher`);
260+
await publisher.goto(
261+
vdoUrl(
262+
target,
263+
`room=${encodeURIComponent(room)}&push=${streamId}&webcam&autostart&chunked=1800&label=Alice&testmedia=1&testwidth=640&testheight=360&testfps=30`
264+
),
265+
{ waitUntil: 'domcontentloaded', timeout: 45000 }
266+
);
267+
await startPublisherIfNeeded(publisher);
268+
await publisher.waitForTimeout(5000);
269+
270+
const viewerUrl = vdoUrl(
271+
target,
272+
`room=${encodeURIComponent(room)}&scene&cleanoutput&view=${streamId}&noaudio&chunked&fixedchunkbuffer&chunkbufferadaptive=0&chunkbufferceil=180000&chunkbuffer=${expectedDelayMs}&buffer=${expectedDelayMs}`
273+
);
274+
275+
logPageDiagnostics(page, `${target.name} room viewer`);
276+
await page.goto(target.companionPath);
277+
await page.setContent(`<iframe id="viewer" src="${viewerUrl}" style="width:100%;height:700px;border:0"></iframe>`);
278+
279+
await waitForAnyBuffer(page, '#viewer', streamId);
280+
await postBufferDelayToIframe(page, '#viewer', expectedDelayMs, streamId);
281+
const observed = await waitForExpectedBuffer(page, '#viewer', streamId, expectedDelayMs);
282+
expect(observed.item?.streamID).toBe(streamId);
283+
284+
await publisherContext.close();
285+
});
286+
287+
test(`popout VDO ${target.name} output applies and updates plugin buffer delay`, async ({ page, browser }) => {
288+
test.setTimeout(150000);
289+
290+
const expectedDelayMs = BUFFER_SEQUENCE[0];
291+
const room = `jw-e2e-${target.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
292+
const streamId = 'alice';
293+
294+
await page.goto(target.companionPath);
295+
await mock.waitForClient();
296+
297+
mock.sendBufferDelay(expectedDelayMs);
298+
await page.waitForTimeout(100);
299+
mock.sendConfig(room, 'viewer');
300+
mock.sendRoster([{ idx: 0, name: 'Alice', streamId }]);
301+
302+
const publisherContext = await browser.newContext({ permissions: ['camera', 'microphone'] });
303+
const publisher = await publisherContext.newPage();
304+
logPageDiagnostics(publisher, `${target.name} jamwide publisher`);
305+
await publisher.goto(
306+
vdoUrl(
307+
target,
308+
`room=${encodeURIComponent(room)}&push=${streamId}&webcam&autostart&chunked=1800&label=Alice&testmedia=1&testwidth=640&testheight=360&testfps=30`
309+
),
310+
{ waitUntil: 'domcontentloaded', timeout: 45000 }
311+
);
312+
await startPublisherIfNeeded(publisher);
313+
await publisher.waitForTimeout(5000);
314+
console.log(`${target.name} jamwide publisherUrl`, publisher.url());
315+
const publisherState = await callVdoTop(publisher, { getDetailedState: true }).catch(err => ({ error: String(err) }));
316+
test.info().annotations.push({ type: `${target.name}PublisherState`, description: JSON.stringify(publisherState).slice(0, 1000) });
317+
318+
const popupPromise = page.waitForEvent('popup');
319+
await page.locator(`.roster-pill[data-stream-id="${streamId}"]`).click();
320+
const popout = await popupPromise;
321+
322+
await expect(popout.locator('#video-area iframe')).toBeVisible({ timeout: 10000 });
323+
324+
let observed = await waitForAnyBuffer(popout, '#video-area iframe', streamId);
325+
expect(observed.item?.streamID).toBe(streamId);
326+
327+
for (const delayMs of BUFFER_SEQUENCE) {
328+
mock.sendBufferDelay(delayMs);
329+
observed = await waitForExpectedBuffer(popout, '#video-area iframe', streamId, delayMs, 30000);
330+
expect(observed.item?.streamID).toBe(streamId);
331+
}
332+
333+
await publisherContext.close();
334+
});
335+
}

companion/e2e/mock-ws-server.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,21 @@ export class MockPluginServer {
4040
}));
4141
}
4242

43-
sendBeatHeartbeat(beat: number, bpi: number, interval: number): void {
44-
this.broadcast(JSON.stringify({
45-
type: 'beatHeartbeat',
46-
beat,
47-
bpi,
48-
interval,
49-
}));
50-
}
43+
sendBeatHeartbeat(beat: number, bpi: number, interval: number): void {
44+
this.broadcast(JSON.stringify({
45+
type: 'beatHeartbeat',
46+
beat,
47+
bpi,
48+
interval,
49+
}));
50+
}
51+
52+
sendRoster(users: Array<{ idx: number; name: string; streamId: string }>): void {
53+
this.broadcast(JSON.stringify({
54+
type: 'roster',
55+
users,
56+
}));
57+
}
5158

5259
sendDeactivate(): void {
5360
this.broadcast(JSON.stringify({ type: 'deactivate' }));

companion/src/__tests__/instamode-sync.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach } from 'vitest';
22
import {
33
setLastAutoDelay,
44
getActiveDelayMs,
5-
getLastAutoDelayMs,
65
getDelayDisplayText,
76
isManualMode,
87
switchToManual,

0 commit comments

Comments
 (0)