// ==UserScript== // @name Virtual Media Studio // @namespace https://virtual.media/ // @version 3.0.0 // @description 虚拟摄像头、麦克风和屏幕共享 // @author Assistant // @match *://*/* // @run-at document-start // @grant GM_registerMenuCommand // @grant GM_addStyle // @license GPL-lv3-or-later // @icon https://obsproject.com/favicon.ico // @downloadURL none // ==/UserScript== (function() { 'use strict'; // ==================== 配置 ==================== const CONFIG_KEY = 'vms_config_v3'; const defaultConfig = { videoType: 'test', videoUrl: '', audioType: 'test', audioUrl: '', screenType: 'test', screenUrl: '', enabled: true, testPattern: 'colorBars' }; const getConfig = () => { try { return Object.assign({}, defaultConfig, JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}')); } catch (e) { return Object.assign({}, defaultConfig); } }; const saveConfig = (cfg) => { localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); }; let config = getConfig(); // ==================== 虚拟设备 ID ==================== const VIRTUAL_CAM_ID = 'virtual-camera-vms-001'; const VIRTUAL_MIC_ID = 'virtual-mic-vms-001'; const VIRTUAL_GROUP = 'virtual-media-studio'; // ==================== IndexedDB ==================== const dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open('VMStudioDB', 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('files')) { db.createObjectStore('files'); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); const loadBlob = async (key) => { try { const db = await dbPromise; return new Promise((resolve) => { const tx = db.transaction('files', 'readonly'); const req = tx.objectStore('files').get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); } catch (e) { return null; } }; const saveBlob = async (key, blob) => { const db = await dbPromise; return new Promise((resolve, reject) => { const tx = db.transaction('files', 'readwrite'); tx.objectStore('files').put(blob, key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }; // ==================== 测试画布 ==================== class TestCanvas { constructor(w, h) { this.canvas = document.createElement('canvas'); this.canvas.width = w; this.canvas.height = h; this.ctx = this.canvas.getContext('2d'); this.frame = 0; this.startTime = Date.now(); } drawColorBars() { const { ctx, canvas } = this; const w = canvas.width; const h = canvas.height; const colors = ['#ffffff', '#ffff00', '#00ffff', '#00ff00', '#ff00ff', '#ff0000', '#0000ff', '#000000']; const barW = w / colors.length; // 彩条 colors.forEach((c, i) => { ctx.fillStyle = c; ctx.fillRect(i * barW, 0, barW, h * 0.7); }); // 灰度条 for (let i = 0; i < 8; i++) { const gray = Math.floor(255 * i / 7); ctx.fillStyle = `rgb(${gray},${gray},${gray})`; ctx.fillRect(i * barW, h * 0.7, barW, h * 0.1); } // 信息区 ctx.fillStyle = '#111'; ctx.fillRect(0, h * 0.8, w, h * 0.2); const now = new Date(); ctx.fillStyle = '#0f0'; ctx.font = `bold ${Math.floor(h * 0.08)}px Consolas, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(now.toLocaleTimeString(), w / 2, h * 0.9); // 帧数 ctx.font = `${Math.floor(h * 0.03)}px Consolas, monospace`; ctx.textAlign = 'left'; ctx.fillStyle = '#ff0'; ctx.fillText(`Frame: ${this.frame}`, 10, h * 0.85); ctx.textAlign = 'right'; const sec = ((Date.now() - this.startTime) / 1000).toFixed(1); ctx.fillText(`${sec}s`, w - 10, h * 0.85); } drawGradient() { const { ctx, canvas } = this; const w = canvas.width; const h = canvas.height; const t = this.frame * 0.02; // 动态渐变背景 const hue1 = (this.frame * 2) % 360; const hue2 = (hue1 + 120) % 360; const grad = ctx.createLinearGradient( w / 2 + Math.cos(t) * w / 2, 0, w / 2 - Math.cos(t) * w / 2, h ); grad.addColorStop(0, `hsl(${hue1}, 80%, 50%)`); grad.addColorStop(1, `hsl(${hue2}, 80%, 50%)`); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); // 浮动圆 for (let i = 0; i < 6; i++) { const x = w / 2 + Math.cos(t + i) * w * 0.3; const y = h / 2 + Math.sin(t * 1.3 + i) * h * 0.3; const r = 20 + Math.sin(t * 2 + i) * 15; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill(); } // 中央文字 ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.floor(h * 0.07)}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 10; ctx.fillText('Virtual Camera', w / 2, h / 2 - 20); ctx.font = `${Math.floor(h * 0.04)}px Arial`; ctx.fillText(new Date().toLocaleTimeString(), w / 2, h / 2 + 25); ctx.shadowBlur = 0; } drawClock() { const { ctx, canvas } = this; const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const r = Math.min(w, h) * 0.35; // 背景 ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, w, h); // 表盘 ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = '#16213e'; ctx.fill(); ctx.strokeStyle = '#667eea'; ctx.lineWidth = 3; ctx.stroke(); // 刻度 for (let i = 0; i < 12; i++) { const ang = (i * 30 - 90) * Math.PI / 180; const len = i % 3 === 0 ? 0.15 : 0.08; ctx.beginPath(); ctx.moveTo(cx + Math.cos(ang) * r * (1 - len), cy + Math.sin(ang) * r * (1 - len)); ctx.lineTo(cx + Math.cos(ang) * r * 0.95, cy + Math.sin(ang) * r * 0.95); ctx.strokeStyle = '#fff'; ctx.lineWidth = i % 3 === 0 ? 3 : 1; ctx.stroke(); } const now = new Date(); const hr = now.getHours() % 12; const mn = now.getMinutes(); const sc = now.getSeconds(); const ms = now.getMilliseconds(); // 时针 const hAng = ((hr + mn / 60) * 30 - 90) * Math.PI / 180; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hAng) * r * 0.5, cy + Math.sin(hAng) * r * 0.5); ctx.strokeStyle = '#fff'; ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.stroke(); // 分针 const mAng = ((mn + sc / 60) * 6 - 90) * Math.PI / 180; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(mAng) * r * 0.7, cy + Math.sin(mAng) * r * 0.7); ctx.strokeStyle = '#ccc'; ctx.lineWidth = 3; ctx.stroke(); // 秒针(平滑) const sAng = ((sc + ms / 1000) * 6 - 90) * Math.PI / 180; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(sAng) * r * 0.85, cy + Math.sin(sAng) * r * 0.85); ctx.strokeStyle = '#f64f59'; ctx.lineWidth = 2; ctx.stroke(); // 中心点 ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fillStyle = '#f64f59'; ctx.fill(); // 数字时间 ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.floor(h * 0.05)}px Consolas`; ctx.textAlign = 'center'; ctx.fillText(now.toLocaleTimeString(), cx, cy + r + 40); } drawNoise() { const { ctx, canvas } = this; const w = canvas.width; const h = canvas.height; const imgData = ctx.createImageData(w, h); const d = imgData.data; for (let i = 0; i < d.length; i += 4) { const v = Math.random() * 255 | 0; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; } ctx.putImageData(imgData, 0, 0); // 叠加文字 ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(w * 0.2, h * 0.4, w * 0.6, h * 0.2); ctx.fillStyle = '#0f0'; ctx.font = `bold ${Math.floor(h * 0.08)}px Consolas`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('NO SIGNAL', w / 2, h / 2); } render(pattern) { this.frame++; switch (pattern) { case 'gradient': this.drawGradient(); break; case 'clock': this.drawClock(); break; case 'noise': this.drawNoise(); break; default: this.drawColorBars(); } return this.canvas; } } // ==================== 虚拟流工厂 ==================== class VirtualStream { static createVideoTrack(source, width, height, pattern) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const testCanvas = new TestCanvas(width, height); let stopped = false; const draw = () => { if (stopped) return; if (source === 'test') { testCanvas.render(pattern); ctx.drawImage(testCanvas.canvas, 0, 0); } else if (source instanceof HTMLVideoElement && source.readyState >= 2) { // 视频源 const vw = source.videoWidth; const vh = source.videoHeight; const scale = Math.min(width / vw, height / vh); const sw = vw * scale; const sh = vh * scale; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, width, height); ctx.drawImage(source, (width - sw) / 2, (height - sh) / 2, sw, sh); } else { // 加载中 ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, width, height); ctx.fillStyle = '#888'; ctx.font = `${height * 0.05}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Loading...', width / 2, height / 2); } requestAnimationFrame(draw); }; draw(); const stream = canvas.captureStream(30); const track = stream.getVideoTracks()[0]; if (track) { const origStop = track.stop.bind(track); track.stop = function() { stopped = true; origStop(); }; } return track; } static createAudioTrack(type) { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const dest = audioCtx.createMediaStreamDestination(); if (type === 'test') { // 440Hz 测试音 const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.frequency.value = 440; gain.gain.value = 0.03; // 低音量 osc.connect(gain); gain.connect(dest); osc.start(); } else { // 静音 const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); gain.gain.value = 0; osc.connect(gain); gain.connect(dest); osc.start(); } return dest.stream.getAudioTracks()[0]; } static async createStream(options) { const tracks = []; if (options.video) { let source = 'test'; const w = 1280, h = 720; if (config.videoType === 'url' && config.videoUrl) { const video = document.createElement('video'); video.src = config.videoUrl; video.loop = true; video.muted = true; video.crossOrigin = 'anonymous'; video.play().catch(() => {}); source = video; } else if (config.videoType === 'local') { const blob = await loadBlob('camera_video'); if (blob) { const video = document.createElement('video'); video.src = URL.createObjectURL(blob); video.loop = true; video.muted = true; video.play().catch(() => {}); source = video; } } tracks.push(this.createVideoTrack(source, w, h, config.testPattern)); } if (options.audio) { tracks.push(this.createAudioTrack(config.audioType)); } return new MediaStream(tracks); } static async createScreenStream(options) { const tracks = []; let source = 'test'; const w = 1920, h = 1080; if (config.screenType === 'url' && config.screenUrl) { const video = document.createElement('video'); video.src = config.screenUrl; video.loop = true; video.muted = true; video.crossOrigin = 'anonymous'; video.play().catch(() => {}); source = video; } else if (config.screenType === 'local') { const blob = await loadBlob('screen_video'); if (blob) { const video = document.createElement('video'); video.src = URL.createObjectURL(blob); video.loop = true; video.muted = true; video.play().catch(() => {}); source = video; } } tracks.push(this.createVideoTrack(source, w, h, config.testPattern)); if (options.audio) { tracks.push(this.createAudioTrack('silent')); } return new MediaStream(tracks); } } // ==================== 工具函数 ==================== function extractDeviceId(constraint) { if (!constraint || typeof constraint !== 'object') return null; const id = constraint.deviceId; if (!id) return null; if (typeof id === 'string') return id; if (typeof id === 'object') { return id.exact || id.ideal || null; } return null; } function isVirtualDevice(id) { return id === VIRTUAL_CAM_ID || id === VIRTUAL_MIC_ID; } // ==================== 原型链劫持 ==================== const origGetUserMedia = MediaDevices.prototype.getUserMedia; const origEnumerateDevices = MediaDevices.prototype.enumerateDevices; const origGetDisplayMedia = MediaDevices.prototype.getDisplayMedia; // 劫持 enumerateDevices - 只添加虚拟设备 MediaDevices.prototype.enumerateDevices = async function() { const devices = await origEnumerateDevices.call(this); if (!config.enabled) { return devices; } // 创建虚拟设备信息对象 const virtualCam = Object.create(MediaDeviceInfo.prototype, { deviceId: { get: function() { return VIRTUAL_CAM_ID; }, enumerable: true }, kind: { get: function() { return 'videoinput'; }, enumerable: true }, label: { get: function() { return '🎥 Virtual Camera (VMS)'; }, enumerable: true }, groupId: { get: function() { return VIRTUAL_GROUP; }, enumerable: true }, toJSON: { value: function() { return { deviceId: VIRTUAL_CAM_ID, kind: 'videoinput', label: '🎥 Virtual Camera (VMS)', groupId: VIRTUAL_GROUP }; }} }); const virtualMic = Object.create(MediaDeviceInfo.prototype, { deviceId: { get: function() { return VIRTUAL_MIC_ID; }, enumerable: true }, kind: { get: function() { return 'audioinput'; }, enumerable: true }, label: { get: function() { return '🎤 Virtual Microphone (VMS)'; }, enumerable: true }, groupId: { get: function() { return VIRTUAL_GROUP; }, enumerable: true }, toJSON: { value: function() { return { deviceId: VIRTUAL_MIC_ID, kind: 'audioinput', label: '🎤 Virtual Microphone (VMS)', groupId: VIRTUAL_GROUP }; }} }); console.log('[VMS] enumerateDevices: 添加虚拟设备'); return [...devices, virtualCam, virtualMic]; }; // 劫持 getUserMedia - 只处理虚拟设备请求 MediaDevices.prototype.getUserMedia = async function(constraints) { if (!config.enabled || !constraints) { return origGetUserMedia.call(this, constraints); } const videoId = extractDeviceId(constraints.video); const audioId = extractDeviceId(constraints.audio); const wantVirtualVideo = videoId === VIRTUAL_CAM_ID; const wantVirtualAudio = audioId === VIRTUAL_MIC_ID; console.log('[VMS] getUserMedia:', { videoId, audioId, wantVirtualVideo, wantVirtualAudio }); // 如果没有请求任何虚拟设备,直接调用原始方法 if (!wantVirtualVideo && !wantVirtualAudio) { return origGetUserMedia.call(this, constraints); } // 构建虚拟流 const virtualTracks = []; const realConstraints = {}; let needRealStream = false; // 处理视频 if (constraints.video) { if (wantVirtualVideo) { const vStream = await VirtualStream.createStream({ video: true, audio: false }); virtualTracks.push(...vStream.getVideoTracks()); } else { realConstraints.video = constraints.video; needRealStream = true; } } // 处理音频 if (constraints.audio) { if (wantVirtualAudio) { const aStream = await VirtualStream.createStream({ video: false, audio: true }); virtualTracks.push(...aStream.getAudioTracks()); } else { realConstraints.audio = constraints.audio; needRealStream = true; } } // 如果还需要真实设备 if (needRealStream && (realConstraints.video || realConstraints.audio)) { try { const realStream = await origGetUserMedia.call(this, realConstraints); realStream.getTracks().forEach(t => virtualTracks.push(t)); } catch (e) { console.warn('[VMS] 获取真实设备失败:', e); } } return new MediaStream(virtualTracks); }; // 劫持 getDisplayMedia if (origGetDisplayMedia) { MediaDevices.prototype.getDisplayMedia = async function(constraints) { if (!config.enabled) { return origGetDisplayMedia.call(this, constraints); } // 检查是否配置了屏幕源 const hasSource = config.screenType === 'test' || (config.screenType === 'url' && config.screenUrl) || config.screenType === 'local'; if (!hasSource) { return origGetDisplayMedia.call(this, constraints); } console.log('[VMS] getDisplayMedia: 返回虚拟屏幕'); return VirtualStream.createScreenStream({ audio: constraints && constraints.audio }); }; } // ==================== UI ==================== const createUI = () => { GM_addStyle(` #vms-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; max-height: 85vh; overflow-y: auto; background: #1a1a2e; z-index: 2147483647; border-radius: 16px; box-shadow: 0 20px 50px rgba(0,0,0,0.8); font-family: system-ui, -apple-system, sans-serif; display: none; color: #e0e0e0; border: 1px solid #333; } #vms-panel * { box-sizing: border-box; margin: 0; padding: 0; } .vms-hd { background: linear-gradient(135deg, #667eea, #764ba2); padding: 18px 20px; display: flex; justify-content: space-between; align-items: center; border-radius: 16px 16px 0 0; } .vms-hd h2 { font-size: 16px; font-weight: 600; color: #fff; } .vms-hd button { background: rgba(255,255,255,0.2); border: none; color: #fff; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 16px; } .vms-hd button:hover { background: rgba(255,255,255,0.3); } .vms-body { padding: 16px; } .vms-tabs { display: flex; gap: 6px; margin-bottom: 16px; } .vms-tabs button { flex: 1; padding: 8px; background: rgba(255,255,255,0.05); border: none; color: #888; border-radius: 8px; cursor: pointer; font-size: 12px; } .vms-tabs button.active { background: #667eea; color: #fff; } .vms-tabs button:hover:not(.active) { background: rgba(255,255,255,0.1); } .vms-tab { display: none; } .vms-tab.active { display: block; } .vms-sec { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 12px; padding: 14px; margin-bottom: 12px; } .vms-sec-title { font-size: 12px; font-weight: 600; color: #a78bfa; margin-bottom: 12px; } .vms-row { margin-bottom: 12px; } .vms-row:last-child { margin-bottom: 0; } .vms-lbl { display: block; font-size: 11px; color: #888; margin-bottom: 5px; } .vms-sel, .vms-inp { width: 100%; padding: 10px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; font-size: 13px; } .vms-sel:focus, .vms-inp:focus { outline: none; border-color: #667eea; } .vms-sel option { background: #1a1a2e; } .vms-file-lbl { display: block; padding: 16px; border: 2px dashed rgba(255,255,255,0.15); border-radius: 8px; text-align: center; cursor: pointer; color: #666; font-size: 13px; } .vms-file-lbl:hover { border-color: #667eea; color: #667eea; } .vms-file-lbl.ok { border-color: #4ade80; color: #4ade80; border-style: solid; } .vms-file-inp { display: none; } .vms-tog { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; } .vms-tog-lbl { font-size: 13px; color: #ddd; } .vms-sw { position: relative; width: 44px; height: 24px; } .vms-sw input { display: none; } .vms-sw span { position: absolute; inset: 0; background: rgba(255,255,255,0.1); border-radius: 24px; cursor: pointer; transition: 0.2s; } .vms-sw span::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.2s; } .vms-sw input:checked + span { background: #667eea; } .vms-sw input:checked + span::before { transform: translateX(20px); } .vms-btn { width: 100%; padding: 12px; border: none; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 8px; } .vms-btn-p { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; } .vms-btn-p:hover { opacity: 0.9; } .vms-btn-s { background: rgba(255,255,255,0.05); color: #aaa; } .vms-btn-s:hover { background: rgba(255,255,255,0.1); } .vms-preview { background: #000; border-radius: 8px; aspect-ratio: 16/9; overflow: hidden; } .vms-preview canvas { width: 100%; height: 100%; } .vms-status { display: flex; gap: 8px; margin-top: 12px; } .vms-status > div { flex: 1; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 10px; text-align: center; } .vms-status .on { color: #4ade80; } .vms-status .off { color: #f87171; } `); const p = document.createElement('div'); p.id = 'vms-panel'; p.innerHTML = `