feat: 3 browser variants with realistic devices, Teams login flow, 20s wait + screenshot
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f0c93c505d
commit
3724e31a30
1 changed files with 139 additions and 165 deletions
|
|
@ -43,9 +43,19 @@ export interface AuthTestResults {
|
||||||
|
|
||||||
const _VARIANTS: AuthTestVariant[] = [
|
const _VARIANTS: AuthTestVariant[] = [
|
||||||
{
|
{
|
||||||
id: 'headfulDirect',
|
id: 'chromiumClean',
|
||||||
name: 'Headful + Direct /v2/',
|
name: 'Chromium Headful Clean',
|
||||||
description: 'Playwright headful, navigiert direkt zu /v2/ URL (umgeht launcher.html komplett)',
|
description: 'Playwright Chromium headful, enhanced stealth + realistic devices, keine fake-flags',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chromiumNoAutomation',
|
||||||
|
name: 'Chromium No-Automation',
|
||||||
|
description: 'Chromium headful, zusaetzlich --disable-extensions, --no-first-run',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rebrowserHeadful',
|
||||||
|
name: 'rebrowser-playwright Headful',
|
||||||
|
description: 'rebrowser-playwright headful, CDP-Leak-Fixes + realistic devices',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -53,40 +63,49 @@ const _VARIANTS: AuthTestVariant[] = [
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const _VARIANT_TIMEOUT_MS = 45000;
|
const _VARIANT_TIMEOUT_MS = 60000;
|
||||||
const _SCREENSHOT_QUALITY = 50;
|
const _SCREENSHOT_QUALITY = 50;
|
||||||
const _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0';
|
const _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0';
|
||||||
|
|
||||||
const _BROWSER_ARGS = [
|
// Base args — NO fake device flags (those show "Fake Default Audio Input" to Teams)
|
||||||
'--use-fake-ui-for-media-stream',
|
const _BROWSER_ARGS_CLEAN = [
|
||||||
'--use-fake-device-for-media-stream',
|
'--use-fake-ui-for-media-stream', // Auto-accept media permissions (no popup)
|
||||||
'--disable-web-security',
|
'--disable-web-security',
|
||||||
'--disable-features=IsolateOrigins,site-per-process',
|
'--disable-features=IsolateOrigins,site-per-process',
|
||||||
'--autoplay-policy=no-user-gesture-required',
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
'--disable-blink-features=AutomationControlled',
|
'--disable-blink-features=AutomationControlled',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Extra args for the no-automation variant
|
||||||
|
const _BROWSER_ARGS_NO_AUTOMATION = [
|
||||||
|
..._BROWSER_ARGS_CLEAN,
|
||||||
|
'--disable-extensions',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-component-update',
|
||||||
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN EXPORT
|
// MAIN EXPORT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run all 6 auth detection test variants against a Teams meeting URL.
|
* Run auth detection test variants: login at teams.microsoft.com, screenshot where we land.
|
||||||
* Does NOT join the meeting — only checks which page Teams serves.
|
* Does NOT navigate to any meeting URL — only tests if Teams login works.
|
||||||
*/
|
*/
|
||||||
export async function runAuthTests(
|
export async function runAuthTests(
|
||||||
meetingUrl: string,
|
meetingUrl: string,
|
||||||
botAccountEmail?: string,
|
botAccountEmail?: string,
|
||||||
botAccountPassword?: string,
|
botAccountPassword?: string,
|
||||||
): Promise<AuthTestResults> {
|
): Promise<AuthTestResults> {
|
||||||
logger.info(`[AuthTest] Starting auth detection tests for: ${meetingUrl}`);
|
logger.info(`[AuthTest] Starting auth tests for: ${meetingUrl} (email=${!!botAccountEmail})`);
|
||||||
|
|
||||||
const results: AuthTestResult[] = [];
|
const results: AuthTestResult[] = [];
|
||||||
|
|
||||||
for (const variant of _VARIANTS) {
|
for (const variant of _VARIANTS) {
|
||||||
// Skip auth variant if no credentials
|
// All variants require credentials
|
||||||
if (variant.id === 'headfulAuth' && (!botAccountEmail || !botAccountPassword)) {
|
if (!botAccountEmail || !botAccountPassword) {
|
||||||
results.push(_createSkippedResult(variant, 'Keine System-Bot Credentials konfiguriert'));
|
results.push(_createSkippedResult(variant, 'Keine Credentials angegeben'));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,21 +181,16 @@ async function _runVariant(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth state
|
// All variants follow the same flow:
|
||||||
|
// 1. Navigate to teams.microsoft.com (triggers Teams-specific login redirect)
|
||||||
|
// 2. Login (email → password → stay signed in)
|
||||||
|
// 3. Wait 20 seconds after login (NO meeting navigation)
|
||||||
|
// 4. Screenshot where we landed
|
||||||
|
|
||||||
let authAttempted = false;
|
let authAttempted = false;
|
||||||
let authSuccess: boolean | null = null;
|
let authSuccess: boolean | null = null;
|
||||||
|
|
||||||
if (variant.id === 'headfulDirect') {
|
_log('info', `Step 1: Navigate to teams.microsoft.com`);
|
||||||
// Strategy: Login via Teams' own auth redirect (not generic MS login).
|
|
||||||
// Generic login.microsoftonline.com sets cookies for m365.cloud.microsoft,
|
|
||||||
// NOT for teams.microsoft.com. We must trigger the Teams-specific login flow.
|
|
||||||
|
|
||||||
if (botAccountEmail && botAccountPassword) {
|
|
||||||
authAttempted = true;
|
|
||||||
_log('info', `Step 1: Navigate to teams.microsoft.com to trigger Teams login redirect`);
|
|
||||||
|
|
||||||
// Navigate to Teams — this redirects to login.microsoftonline.com with
|
|
||||||
// Teams-specific client_id and redirect_uri=teams.microsoft.com
|
|
||||||
await page.goto('https://teams.microsoft.com', {
|
await page.goto('https://teams.microsoft.com', {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
|
@ -184,64 +198,22 @@ async function _runVariant(
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
_log('info', `After Teams redirect: ${page.url().substring(0, 120)}`);
|
_log('info', `After Teams redirect: ${page.url().substring(0, 120)}`);
|
||||||
|
|
||||||
// Now we should be on login.microsoftonline.com with Teams context.
|
// Step 2: Login
|
||||||
// Use AuthProcedure with skipNavigation=true (we're already on the login page)
|
if (botAccountEmail && botAccountPassword) {
|
||||||
const authProcedure = new AuthProcedure(page, logger);
|
authAttempted = true;
|
||||||
const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com');
|
const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com');
|
||||||
|
_log('info', `On login page: ${onLoginPage}, authenticating as ${botAccountEmail}`);
|
||||||
|
|
||||||
if (onLoginPage) {
|
const authProcedure = new AuthProcedure(page, logger);
|
||||||
_log('info', `On MS login page, authenticating as ${botAccountEmail}`);
|
// skipNavigation=true because we're already on the login page after teams.microsoft.com redirect
|
||||||
authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false);
|
authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, !onLoginPage);
|
||||||
} else {
|
|
||||||
// Maybe already logged in, or Teams served directly
|
|
||||||
_log('info', `Not on login page (${page.url().substring(0, 80)}), trying direct auth`);
|
|
||||||
authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
_log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`);
|
_log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`);
|
||||||
|
|
||||||
if (authSuccess) {
|
|
||||||
// Wait for redirect chain to complete (MS → Teams)
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
_log('info', `After auth settle: ${page.url().substring(0, 120)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log('info', 'No credentials provided — skipping auth');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Navigate to /v2/ meeting URL (with Teams auth cookies)
|
// Step 3: Wait 20 seconds — NO further navigation
|
||||||
const directUrl = _buildDirectV2Url(meetingUrl);
|
_log('info', 'Waiting 20s after login (no meeting navigation)...');
|
||||||
_log('info', `Step 2: Direct /v2/ navigation: ${directUrl.substring(0, 120)}`);
|
await page.waitForTimeout(20000);
|
||||||
|
_log('info', `Final URL after 20s: ${page.url().substring(0, 150)}`);
|
||||||
await page.goto(directUrl, {
|
|
||||||
waitUntil: 'domcontentloaded',
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for page to settle
|
|
||||||
await page.waitForTimeout(8000);
|
|
||||||
const currentUrl = page.url();
|
|
||||||
_log('info', `After meeting load: ${currentUrl.substring(0, 120)}`);
|
|
||||||
|
|
||||||
if (currentUrl.includes('light-meetings')) {
|
|
||||||
_log('warn', 'Teams redirected /v2/ to light-meetings (even after auth)');
|
|
||||||
} else if (currentUrl.includes('/v2/')) {
|
|
||||||
_log('info', '/v2/ page loaded after auth!');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Standard flow: resolve URL, navigate, click launcher
|
|
||||||
const useAnon = variant.id === 'baseline';
|
|
||||||
const launchUrl = await _resolveMeetingUrl(meetingUrl, useAnon);
|
|
||||||
_log('info', `Navigating to: ${launchUrl.substring(0, 100)}`);
|
|
||||||
|
|
||||||
await page.goto(launchUrl, {
|
|
||||||
waitUntil: 'domcontentloaded',
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await _handleLauncher(page);
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect page type and gather signals
|
// Detect page type and gather signals
|
||||||
const detection = await _detectPageType(page);
|
const detection = await _detectPageType(page);
|
||||||
|
|
@ -319,30 +291,20 @@ interface LaunchResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _launchBrowserForVariant(variant: AuthTestVariant): Promise<LaunchResult> {
|
async function _launchBrowserForVariant(variant: AuthTestVariant): Promise<LaunchResult> {
|
||||||
|
_requireDisplay(); // All variants are headful
|
||||||
|
|
||||||
switch (variant.id) {
|
switch (variant.id) {
|
||||||
case 'baseline':
|
case 'chromiumClean':
|
||||||
return _launchVanilla(true, 'basic');
|
return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
|
||||||
|
|
||||||
case 'rebrowser':
|
case 'chromiumNoAutomation':
|
||||||
return _launchRebrowser();
|
return _launchChromiumHeadful(_BROWSER_ARGS_NO_AUTOMATION);
|
||||||
|
|
||||||
case 'playwrightExtra':
|
case 'rebrowserHeadful':
|
||||||
return _launchPlaywrightExtra();
|
return _launchRebrowserHeadful();
|
||||||
|
|
||||||
case 'headful':
|
|
||||||
_requireDisplay();
|
|
||||||
return _launchVanilla(false, 'enhanced');
|
|
||||||
|
|
||||||
case 'headfulAuth':
|
|
||||||
_requireDisplay();
|
|
||||||
return _launchVanilla(false, 'enhanced');
|
|
||||||
|
|
||||||
case 'headfulDirect':
|
|
||||||
_requireDisplay();
|
|
||||||
return _launchVanilla(false, 'enhanced');
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return _launchVanilla(true, 'basic');
|
return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,103 +325,58 @@ function _requireDisplay(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch vanilla Playwright with configurable headless mode and stealth level.
|
* Launch Chromium headful with enhanced stealth + realistic device spoofing.
|
||||||
*/
|
*/
|
||||||
async function _launchVanilla(headless: boolean, stealthLevel: 'basic' | 'enhanced'): Promise<LaunchResult> {
|
async function _launchChromiumHeadful(args: string[]): Promise<LaunchResult> {
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless,
|
headless: false,
|
||||||
args: _BROWSER_ARGS,
|
args,
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
userAgent: _USER_AGENT,
|
userAgent: _USER_AGENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
if (stealthLevel === 'basic') {
|
|
||||||
await page.addInitScript(_getBasicStealthScript);
|
|
||||||
} else {
|
|
||||||
await page.addInitScript(_getEnhancedStealthScript);
|
await page.addInitScript(_getEnhancedStealthScript);
|
||||||
}
|
await page.addInitScript(_getDeviceSpoofScript);
|
||||||
|
|
||||||
return { browser, context, page };
|
return { browser, context, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch rebrowser-playwright (patched Playwright with CDP leak fixes).
|
* Launch rebrowser-playwright headful with CDP leak fixes + realistic devices.
|
||||||
* Uses dynamic require to avoid import errors if package is not installed.
|
|
||||||
*
|
|
||||||
* IMPORTANT: rebrowser-playwright ships its own playwright-core which may
|
|
||||||
* expect a different Chromium version than what's installed in the Docker image.
|
|
||||||
* We solve this by passing `executablePath` from vanilla Playwright, so
|
|
||||||
* rebrowser uses its patched CDP handling with the available Chromium binary.
|
|
||||||
*/
|
*/
|
||||||
async function _launchRebrowser(): Promise<LaunchResult> {
|
async function _launchRebrowserHeadful(): Promise<LaunchResult> {
|
||||||
let rbChromium: typeof chromium;
|
let rbChromium: typeof chromium;
|
||||||
try {
|
try {
|
||||||
const rb = require('rebrowser-playwright');
|
const rb = require('rebrowser-playwright');
|
||||||
rbChromium = rb.chromium;
|
rbChromium = rb.chromium;
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn('[AuthTest] rebrowser-playwright not installed, falling back to vanilla');
|
logger.warn('[AuthTest] rebrowser-playwright not installed, falling back to vanilla');
|
||||||
return _launchVanilla(true, 'enhanced');
|
return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the Chromium binary from vanilla Playwright (matches Docker image)
|
|
||||||
// to avoid "Executable doesn't exist" errors from version mismatch
|
|
||||||
const execPath = chromium.executablePath();
|
const execPath = chromium.executablePath();
|
||||||
logger.info(`[AuthTest:rebrowser] Using Chromium binary: ${execPath}`);
|
logger.info(`[AuthTest:rebrowser] Using Chromium binary: ${execPath}`);
|
||||||
|
|
||||||
const browser = await rbChromium.launch({
|
const browser = await rbChromium.launch({
|
||||||
headless: true,
|
headless: false,
|
||||||
executablePath: execPath,
|
executablePath: execPath,
|
||||||
args: _BROWSER_ARGS,
|
args: _BROWSER_ARGS_CLEAN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
userAgent: _USER_AGENT,
|
userAgent: _USER_AGENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// rebrowser-playwright handles most stealth automatically, but add enhanced overrides too
|
|
||||||
await page.addInitScript(_getEnhancedStealthScript);
|
await page.addInitScript(_getEnhancedStealthScript);
|
||||||
|
await page.addInitScript(_getDeviceSpoofScript);
|
||||||
return { browser, context, page };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch playwright-extra with the stealth plugin.
|
|
||||||
* Uses dynamic require to avoid import errors if package is not installed.
|
|
||||||
*/
|
|
||||||
async function _launchPlaywrightExtra(): Promise<LaunchResult> {
|
|
||||||
let chromiumExtra: typeof chromium;
|
|
||||||
try {
|
|
||||||
const { chromium: pweChromium } = require('playwright-extra');
|
|
||||||
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
|
||||||
pweChromium.use(StealthPlugin());
|
|
||||||
chromiumExtra = pweChromium;
|
|
||||||
} catch {
|
|
||||||
logger.warn('[AuthTest] playwright-extra not installed, falling back to vanilla');
|
|
||||||
return _launchVanilla(true, 'enhanced');
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromiumExtra.launch({
|
|
||||||
headless: true,
|
|
||||||
args: _BROWSER_ARGS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
permissions: ['microphone', 'camera'],
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
userAgent: _USER_AGENT,
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
return { browser, context, page };
|
return { browser, context, page };
|
||||||
}
|
}
|
||||||
|
|
@ -594,6 +511,63 @@ function _getEnhancedStealthScript(): void {
|
||||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device spoofing script — replaces "Fake Default Audio Input" with realistic device names.
|
||||||
|
* Without --use-fake-device-for-media-stream, getUserMedia may fail, so we mock it too.
|
||||||
|
*/
|
||||||
|
function _getDeviceSpoofScript(): void {
|
||||||
|
// Override enumerateDevices to return realistic device names
|
||||||
|
if (navigator.mediaDevices) {
|
||||||
|
const _originalEnumerateDevices = navigator.mediaDevices.enumerateDevices?.bind(navigator.mediaDevices);
|
||||||
|
|
||||||
|
navigator.mediaDevices.enumerateDevices = async () => {
|
||||||
|
// Return realistic Windows devices
|
||||||
|
return [
|
||||||
|
{ deviceId: 'default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||||
|
{ deviceId: 'comm-default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||||
|
{ deviceId: 'default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||||
|
{ deviceId: 'comm-default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||||
|
{ deviceId: 'webcam-01', kind: 'videoinput', label: 'HD Webcam C270', groupId: 'e5f6g7h8', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override getUserMedia to return a valid (silent/black) MediaStream
|
||||||
|
const _originalGetUserMedia = navigator.mediaDevices.getUserMedia?.bind(navigator.mediaDevices);
|
||||||
|
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
||||||
|
try {
|
||||||
|
// Try real getUserMedia first (works if actual devices exist)
|
||||||
|
if (_originalGetUserMedia) {
|
||||||
|
return await _originalGetUserMedia(constraints);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: return empty stream
|
||||||
|
}
|
||||||
|
// Create a silent audio track + black video track
|
||||||
|
const stream = new MediaStream();
|
||||||
|
// @ts-ignore - AudioContext may not be available in all envs
|
||||||
|
try {
|
||||||
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
const dest = audioCtx.createMediaStreamDestination();
|
||||||
|
oscillator.connect(dest);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioCtx.currentTime + 0.01);
|
||||||
|
dest.stream.getAudioTracks().forEach(t => stream.addTrack(t));
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 640;
|
||||||
|
canvas.height = 480;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) ctx.fillRect(0, 0, 640, 480);
|
||||||
|
const videoStream = canvas.captureStream(1);
|
||||||
|
videoStream.getVideoTracks().forEach(t => stream.addTrack(t));
|
||||||
|
} catch {}
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// URL RESOLUTION
|
// URL RESOLUTION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue