您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Change how you look on Google Meet.
// ==UserScript== // @name Mercator Studio for Google Meet // @version 2.2.1 // @description Change how you look on Google Meet. // @author Xing <[email protected]> (https://x-ing.space) // @copyright 2020-2021, Xing (https://x-ing.space) // @license MIT License; https://x-ing.space/mercator/LICENSE // @namespace https://x-ing.space // @homepageURL https://x-ing.space/mercator // @icon https://x-ing.space/mercator/icon.png // @match https://meet.google.com/* // @grant none // ==/UserScript== (async function mercator_studio() { 'use strict' // Create shadow root const host = document.createElement('aside') host.style.position = 'absolute' host.style.zIndex = 10 host.style.pointerEvents = 'none' const shadow = host.attachShadow({ mode: 'open' }) const isFirefox = navigator.userAgent.includes('Firefox') // Create form const main = document.createElement('main') const style = document.createElement('style') const body_fonts = 'Roboto, RobotDraft, Helvetica, sans-serif, serif' const display_fonts = '"Google Sans", ' + body_fonts style.textContent = ` a, button { all: unset; cursor: pointer; text-align: center; } main, main *, a, button { box-sizing: border-box; transition-duration: 200ms; transition-property: opacity, background, transform, padding, border-radius, border-color; color: inherit; font-family: inherit; font-size: inherit; font-weight: inherit; } :not(input) { user-select: none; } @media (prefers-reduced-motion) { * { transition-duration: 0s; } } :focus { outline: 0; } /* -- */ main { --bg: #3C4042; --bg-x: #434649; --bg-xx: #505457; --txt: white; font-family: ${display_fonts}; font-size: 0.9rem; width: 25rem; max-width: 100vw; height: 100vh; position: fixed; bottom: 0; left: 0; padding: 0.5rem; display: flex; flex-direction: column-reverse; overflow: hidden; pointer-events: none; } main > * { color: var(--txt); } #fields, #bar, #labels > * { border-radius: .5rem; box-shadow: 0 .1rem .25rem #0004; pointer-events: all; } :not(.edit)>#fields{ display: none; opacity: 0; } :not(.edit)>#bar{ border-radius: 1.5rem; flex-basis: 4rem; } #text:hover, #text:focus, #presets:hover, #bar>:hover, #bar>:focus, #tips > * { background: var(--bg-x); } #text:hover:focus, #presets:hover, #bar>:hover:focus { background: var(--bg-xx); } /* -- */ #tips { position: relative; font-family: ${body_fonts}; font-size: 0.8rem; line-height: 1rem; z-index: 10; } #tips > * { display: block; position: absolute; bottom: 0rem; height: 1.5rem; padding: 0.25rem; border-radius: 0.25rem; } #tips > :not(.show) { opacity: 0; } #tips > [for="minimize"] { left: 0; } #tips > [for="previews"] { left: 50%; transform: translateX(-50%); } #tips > [for="donate"] { right: 0; } .edit > #tips > * { top: 1rem; } /* -- */ #bar { margin-top: .5rem; overflow: hidden; flex: 0 0 auto; display: flex; } .minimize #bar { width: 1rem; } #bar > * { background: var(--bg); } #bar #minimize, #bar #donate { font-size: .5rem; flex: 0 0 1.5rem; width: var(--radius); text-align: center; line-height: 4rem; height: 100%; overflow-wrap: anywhere; } .edit #bar #minimize, .edit #bar #donate, .edit #bar h2, .minimize #bar :not(#minimize) { display: none; } :not(.minimize) #minimize:hover, .minimize #minimize:not(:hover) { padding-right: 2px; } #donate:hover { padding-left: 2px; } .minimize #minimize{ flex-basis: 1rem; } #previews { flex: 1 0 0; width: 0; display: flex; } #previews video, #previews canvas { width: auto; height: auto; background-image: linear-gradient(90deg, hsl( 18, 100%, 68%) 16.7%, hsl(-10, 100%, 80%) 16.7%, hsl(-10, 100%, 80%) 33.3%, hsl(5,90%, 72%) 33.3%, hsl(5,90%, 72%) 50%, hsl( 48, 100%, 75%) 50%, hsl( 48, 100%, 75%) 66.7%, hsl( 36, 100%, 70%) 66.7%, hsl( 36, 100%, 70%) 83.3%, hsl( 20,90%, 70%) 83.3% ); } .edit #previews video, .edit #previews canvas { height: auto; max-width: 50%; object-fit: contain; } #previews>h2 { flex-grow: 1; font-size: .9rem; line-height: 1.4; display: flex; text-align: center; align-items: center; justify-content: center; } #previews:hover>h2 { transform: translateY(-2px); } /* -- */ #fields { display: flex; flex-direction: column; overflow: hidden scroll; padding: 1rem; flex: 0 1 auto; background: var(--bg); } #presets, #fields > label { display: flex; justify-content: space-between; align-items: center; } #fields > label+label { margin-top: 0.5rem; } #fields > label:focus-within{ font-weight: bold; } #fields > label > * { width: calc(100% - 4.5rem); height: 1rem; border-radius: 0.5rem; border: 0.15rem solid var(--bg-x); font-size: 0.8rem; } #presets:focus-within, #fields > label > :focus, #fields > label > :hover { border-width: 0.15rem; border-color: var(--txt); } #fields > label > #presets { overflow: hidden; height: auto; margin-bottom: -0.15rem; } #presets>* { border: 0; border-radius: 0; background: transparent; flex-grow: 1; height: 1.3rem; font-weight: normal; } #presets>:first-child { border-radius: 0.25rem 0 0 0.25rem; } #presets>:last-child { border-radius: 0 0.25rem 0.25rem 0; } #presets>:hover { background: var(--bg); } #presets>:focus { background: var(--txt); color: var(--bg); } #fields > label > #text { text-align: center; font-weight: bold; resize: none; line-height: 1.1; overflow: hidden scroll; background: var(--bg); height: auto; } #text::placeholder { color: inherit; } #text::selection { color: var(--bg); background: var(--txt); } input[type=checkbox] { cursor: pointer; } input[type=range] { -webkit-appearance: none; cursor: ew-resize; --gradient: transparent, transparent; --rainbow: hsl(0, 80%, 75%), hsl(30, 80%, 75%), hsl(60, 80%, 75%), hsl(90, 80%, 75%), hsl(120, 80%, 75%), hsl(150, 80%, 75%), hsl(180, 80%, 75%), hsl(210, 80%, 75%), hsl(240, 80%, 75%), hsl(270, 80%, 75%), hsl(300, 80%, 75%), hsl(330, 80%, 75%); background: linear-gradient(90deg, var(--gradient)), linear-gradient(90deg, var(--rainbow)); } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; transition: inherit; background: var(--bg); width: 1rem; height: 1rem; border: 0.1rem solid var(--txt); transform: scale(1.5); border-radius: 100%; } input[type=range]:hover::-webkit-slider-thumb { background: var(--bg-x); } input[type=range]:focus::-webkit-slider-thumb { border-color: var(--bg); background: var(--txt); } input[type=range]::-moz-range-thumb { transition: inherit; background: var(--bg); width: 1rem; height: 1rem; border: 0.1rem solid var(--txt); transform: scale(1.5); border-radius: 100%; box-sizing: border-box; } input[type=range]:hover::-moz-range-thumb { background: var(--bg-x); } input[type=range]:focus::-moz-range-thumb { border-color: var(--bg); background: var(--txt); } input#light, input#fade, input#vignette { --gradient: black, #8880, white } input#contrast { --gradient: gray, #8880 } input#warmth, input#tilt { --gradient: #88f, #8880, #ff8 } input#tint, input#pan { --gradient: #f8f, #8880, #8f8 } input#sepia { --gradient: #8880, #aa8 } input#hue, input#rotate { background: linear-gradient(90deg, hsl(0, 80%, 75%), hsl(60, 80%, 75%), hsl(120, 80%, 75%), hsl(180, 80%, 75%), hsl(240, 80%, 75%), hsl(300, 80%, 75%), hsl(0, 80%, 75%), hsl(60, 80%, 75%), hsl(120, 80%, 75%), hsl(180, 80%, 75%), hsl(240, 80%, 75%), hsl(300, 80%, 75%), hsl(0, 80%, 75%)) } input#saturate { --gradient: gray, #8880 50%, blue, magenta } input#blur { --gradient: #8880, gray } input#scale, input#pillarbox, input#letterbox { --gradient: black, white } ` // Translate labels // Top languages of users: English, Portuguese, Spanish, Italian, Polish const i18n = { light: { en: 'light', es: 'brillo', fr: 'lumin', it: 'lumin', pt: 'brilho', zh: '亮度' }, contrast: { en: 'contrast', es: 'contraste', fr: 'contraste', it: 'contrasto', pt: 'contraste', zh: '对比度' }, warmth: { en: 'warmth', es: 'calor', fr: 'chaleur', it: 'calore', pt: 'calor', zh: '温度' }, tint: { en: 'tint', es: 'tinción', fr: 'teinte', it: 'tinta', pt: 'verde', zh: '色调' }, sepia: { en: 'sepia', es: 'sepia', fr: 'sépia', it: 'seppia', pt: 'sépia', zh: '泛黄' }, hue: { en: 'hue', es: 'tono', fr: 'ton', it: 'tonalità', pt: 'matiz', zh: '色相' }, saturate: { en: 'saturate', es: 'satura', fr: 'sature', it: 'saturare', pt: 'satura', zh: '饱和度' }, blur: { en: 'blur', es: 'difuminar', fr: 'flou', it: 'sfocatura', pt: 'enevoa', zh: '模糊' }, fade: { en: 'fade', es: 'fundido', fr: 'fondu', it: 'svanisci', pt: 'fundido', zh: '淡出' }, vignette: { en: 'vignette', es: 'viñeta', fr: 'vignette', it: 'vignetta', pt: 'vinheta', zh: '虚光照' }, rotate: { en: 'rotate', es: 'rota', fr: 'pivote', it: 'ruoti', pt: 'rota', zh: '旋转' }, scale: { en: 'scale', es: 'zoom', fr: 'zoom', it: 'scala', pt: 'zoom', zh: '大小' }, pan: { en: 'pan', es: 'panea', fr: 'pan', it: 'sposti-h', pt: 'panea', zh: '左右移动' }, tilt: { en: 'tilt', es: 'inclina', fr: 'incline', it: 'sposti-v', pt: 'empina', zh: '上下移动' }, pillarbox: { en: 'pillarbox', es: 'recorta-h', fr: 'taille-h', it: 'tagli-h', pt: 'recorta-h', zh: '左右裁剪' }, letterbox: { en: 'letterbox', es: 'recorta-v', fr: 'taille-v', it: 'tagli-v', pt: 'recorta-v', zh: '上下裁剪' }, text: { en: 'text', es: 'texto', fr: 'texte', it: 'testo', pt: 'texto', zh: '文字' }, mirror: { en: 'mirror', es: 'refleja', fr: 'réfléch', it: 'rispecchi', pt: 'refleja', zh: '反射' }, freeze: { en: 'freeze', es: 'pausa', fr: 'arrête', it: 'pausa', pt: 'pausa', zh: '暂停' }, presets: { en: 'presets', es: 'estilos', fr: 'styles', it: 'stili', pt: 'estilos', zh: '预设' }, preset: { en: 'preset: ', es: 'estilo: ', fr: 'style: ', it: 'stile: ', pt: 'estilo: ', zh: '预设:' }, reset: { en: 'reset', es: 'reini', fr: 'réinit', it: 'reset', pt: 'reini', zh: '重置' }, open_tip: { en: 'Open', es: 'Abre', fr: 'Ouvre', it: 'Apri', pt: 'Aberto', zh: '打开' }, close_tip: { en: 'Close', es: 'Cierra', fr: 'Ferme', it: 'Chiudi', pt: 'Feche', zh: '合起' }, minimize_tip: { en: 'Minimize', es: 'Minimizas', fr: 'Minimise', it: 'Minimizzi', pt: 'Minimiza', zh: '合起' }, previews_tip: { en: ' previews', es: ' visualizaciones', fr: ' aperçus', it: ' anteprima', pt: 'visualizações', zh: '预览' }, studio_tip: { en: ' studio', es: ' estudio', fr: ' studio', it: ' studio', pt: ' estúdio', zh: '画室' }, text_tip: { en: 'Write text here', es: 'Escribe el texto aquí', fr: 'Écrivez du texte ici', it: 'Scrivi il testo qui', pt: 'Escreva o texto aqui', zh: '在这里写字' }, donate_tip: { en: 'Donate to the dev', es: 'Donas al dev', fr: 'Fais un don au dev', it: 'Donare al dev', pt: 'Você doa para o dev', zh: '捐款给作者' }, } const langs = [ 'en', 'es', 'fr', 'it', 'pt', 'zh' ] main.lang = langs.find( x => x === navigator.language.split('-')[0] ) || 'en' for(const key in i18n) i18n[key] = i18n[key][main.lang] // Create inputs const fields = document.createElement('section') fields.id= 'fields' const types = { light: 'range', contrast: 'range', warmth: 'range', tint: 'range', sepia: 'range_positive', hue: 'range_loop', saturate: 'range', blur: 'range_positive', fade: 'range', vignette: 'range', rotate: 'range_loop', scale: 'range_positive', pan: 'range', tilt: 'range', pillarbox: 'range_positive', letterbox: 'range_positive', text: 'textarea', mirror: 'checkbox', freeze: 'checkbox', presets: 'radio', } const default_values = { light: 0, contrast: 0, warmth: 0, tint: 0, sepia: 0, hue: 0, saturate: 0, blur: 0, fade: 0, vignette: 0, rotate: 0, scale: 0, pan: 0, tilt: 0, pillarbox: 0, letterbox: 0, text: '', mirror: false, freeze: false, presets: 'reset', } const saved_values = JSON.parse(window.localStorage.getItem('mercator-studio-values-20')) || {} const preset_values = { reset: {}, concorde: { contrast: 0.1, warmth: -0.25, tint: -0.05, saturate: 0.2, }, mono: { light: 0.1, contrast: -0.1, sepia: 0.8, saturate: -1, vignette: -0.5, }, matcha: { light: 0.1, tint: -0.75, sepia: 1, hue: 0.2, vignette: 0.3, fade: 0.3, }, deepfry: { contrast: 1, saturate: 0.5, } } // Clone default values into updating object const values = { ...default_values, ...saved_values } const inputs = Object.fromEntries( Object.entries(values) .map(([key, value]) => { let input const type = types[key] switch (type) { case 'textarea': input = document.createElement('textarea') input.rows = 3 input.placeholder = `\n🌈 ${i18n.text_tip} 🌦️` input.addEventListener('input', () => { // String substitution set_value(input, (input.value + '') .replace(/--/g, '―') .replace(/\\sqrt/g, '√') .replace(/\\pm/g, '±') .replace(/\\times/g, '×') .replace(/\\cdot/g, '·') .replace(/\\over/g, '∕') // Numbers starting with ^ (superscript) or _ (subscript) .replace(/(\^|\_)(\d+)/g, (_, sign, number) => number.split('').map(digit => String.fromCharCode(digit.charCodeAt(0) + ( // Difference in character codes between subscript numbers and their regular equivalents. sign === '_' ? 8272 : // Superscript 1, 2 & 3 are in separate ranges. digit === '1' ? 136 : '23'.includes(digit) ? 128 : 8256 )) ).join('') ) ) }) break case 'checkbox': input = document.createElement('input') input.type = 'checkbox' input.addEventListener('change', () => set_value(input, input.checked) ) break case 'radio': input = document.createElement('label') input.append(...Object.keys(preset_values).map(key => { const button = document.createElement('button') button.textContent = ( key === 'reset' ) ? i18n.reset : key button.setAttribute('aria-label', i18n.preset + button.textContent) button.addEventListener('click', event => { event.preventDefault() Object.entries({...default_values,...preset_values[key]}) .forEach(([key, value]) => set_value(inputs[key], value)) }) return button })) break default: input = document.createElement('input') input.type = 'range' // These inputs go from 0 to 1, the rest -1 to 1 input.min = ( type === 'range_positive' ) - 1 input.max = 1 // Use 32 steps normally, 128 if CTRL, 512 if SHIFT const range = input.max - input.min input.step = range / 32 input.addEventListener('keydown', ({ code, ctrlKey, shiftKey }) => { if(code === 'Digit0') reset_value(input) input.step = range / (shiftKey ? 512 : ctrlKey ? 128 : 32) }) input.addEventListener('keyup', () => input.step = range / 32 ) input.addEventListener('input', () => { input.focus() set_value(input, input.valueAsNumber) }) // Scroll to change values input.addEventListener('wheel', event => { event.preventDefault() input.focus() const width = input.getBoundingClientRect().width const dx = -event.deltaX const dy = event.deltaY const ratio = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / width const range = input.max - input.min const raw_value = input.valueAsNumber + ratio * range const clamped_value = Math.min(Math.max(raw_value, input.min), input.max) const stepped_value = Math.round(clamped_value / input.step) * input.step const value = stepped_value set_value(input, value) }) // Right click to individually reset input.addEventListener('contextmenu', event => { event.preventDefault() reset_value(input) }) } input.value = value input.id = key if (!(isFirefox && ['warmth', 'tint'].includes(key))) { // Disable the SVG filters for Firefox let label = document.createElement('label') label.textContent = i18n[key] label.append(input) fields.append(label) } return [key, input] }) ) function set_value(input, value) { values[input.id] = input.value = value window.localStorage.setItem('mercator-studio-values-20', JSON.stringify(values)) } function reset_value(input) { set_value(input, default_values[input.id]) } // Create color balance matrix const svgNS = 'http://www.w3.org/2000/svg' const svg = document.createElementNS(svgNS, 'svg') const filter = document.createElementNS(svgNS, 'filter') filter.id = 'filter' const component_transfer = document.createElementNS(svgNS, 'feComponentTransfer') const components = Object.fromEntries( ['R', 'G', 'B'].map(hue => { const func = document.createElementNS(svgNS, 'feFunc' + hue) func.setAttribute('type', 'table') func.setAttribute('tableValues', '0 1') return [hue, func] })) component_transfer.append(...Object.values(components)) filter.append(component_transfer) svg.append(filter) // Create labels const minimize_tip = document.createElement('label') minimize_tip.htmlFor = 'minimize' minimize_tip.dataset.off = `${i18n.minimize_tip}${i18n.previews_tip} (ctrl + shift + m)` minimize_tip.dataset.on = `${i18n.open_tip}${i18n.previews_tip} (ctrl + shift + m)` minimize_tip.textContent = minimize_tip.dataset.off const previews_tip = document.createElement('label') previews_tip.htmlFor = 'previews' previews_tip.dataset.off = `${i18n.open_tip}${i18n.studio_tip} (ctrl + m)` previews_tip.dataset.on = `${i18n.close_tip}${i18n.studio_tip} (ctrl + m)` previews_tip.textContent = previews_tip.dataset.off const donate_tip = document.createElement('label') donate_tip.htmlFor = 'donate' donate_tip.textContent = i18n.donate_tip const tips = document.createElement('section') tips.id = 'tips' tips.append(minimize_tip,previews_tip,donate_tip) // Mimic Google Meet tooltip behavior where hover gets priority over focused const update_tips = () => { tips.querySelectorAll('.show').forEach(tip=>tip.classList.remove('show')) const show = tips.querySelector('.hover') || tips.querySelector('.focus') if(show) show.classList.add('show') } const link_tip = ( original, tip ) => { original.addEventListener('mouseenter',()=>{ tip.classList.add('hover') update_tips() }) original.addEventListener('mouseleave',()=>{ tip.classList.remove('hover') update_tips() }) original.addEventListener('focus',()=>{ tip.classList.add('focus') update_tips() }) original.addEventListener('blur',()=>{ tip.classList.remove('focus') update_tips() }) } // create bottom bar const bar = document.createElement('section') bar.id = 'bar' const minimize = document.createElement('button') minimize.id = 'minimize' minimize.textContent = '◀' const toggleMinimize = () => { main.classList.remove('edit') main.classList.toggle('minimize') minimize.focus() const state = main.classList.contains('minimize') minimize.textContent = state ? '▶' : '◀' minimize_tip.textContent = minimize_tip.dataset[ state ? 'on' : 'off' ] minimize_tip.classList.remove('focus') update_tips() } minimize.addEventListener('click', toggleMinimize) link_tip(minimize,minimize_tip) const donate = document.createElement('a') donate.id = 'donate' donate.href = 'https://ko-fi.com/xingyzt' donate.target = '_blank' donate.textContent = '🤍' donate.setAttribute('aria-label',i18n.donate_tip) link_tip(donate,donate_tip) // Create previews const previews = document.createElement('button') previews.id = 'previews' const toggleEdit = () => { main.classList.remove('minimize') main.classList.toggle('edit') previews.focus() const state = main.classList.contains('edit') state ? Object.values(inputs)[0].focus() : previews.focus() previews_tip.textContent = previews_tip.dataset[state ? 'on' : 'off'] previews_tip.classList.remove('focus') update_tips() } previews.addEventListener('click', toggleEdit) link_tip(previews,previews_tip) // Ctrl+m to toggle window.addEventListener('keydown', event => { if (event.code=='KeyM' && event.ctrlKey) { event.preventDefault() event.shiftKey ? toggleMinimize(event) : toggleEdit(event) } }) // Create preview video const video = document.createElement('video') video.setAttribute('playsinline', '') video.setAttribute('autoplay', '') video.setAttribute('muted', '') // Create canvases const canvases = Object.fromEntries(['buffer', 'freeze', 'display'].map(name => { const element = document.createElement('canvas') const context = element.getContext('2d') return [name, { element, context }] })) // Create title const title = document.createElement('h2') title.id = 'title' title.innerText = 'Mercator\nStudio' previews.append(video, title, canvases.buffer.element) bar.append(minimize, previews, donate) // Add UI to page main.append(bar, tips, fields) shadow.append(main, style, svg) document.body.append(host) // Define mappings of linear values const polynomial_map = (value, degree) => (value + 1) ** degree const polynomial_table = (factor, steps = 32) => Array(steps).fill(0) .map((_, index) => Math.pow(index / (steps - 1), 2 ** factor)).join(' ') const percentage = (value) => value * 100 + '%' const amp = 8 let task = 0 // Background Blur for Google Meet does this ([email protected]) class mercator_studio_MediaStream extends MediaStream { constructor(old_stream) { // Copy original stream settings super(old_stream) video.srcObject = old_stream const old_stream_settings = old_stream.getVideoTracks()[0].getSettings() const w = old_stream_settings.width const h = old_stream_settings.height const center = [w / 2, h / 2] Object.values(canvases).forEach(canvas => { canvas.element.width = w canvas.element.height = h }) const canvas = canvases.buffer.buffer const context = canvases.buffer.context const freeze = { state: false, init: false, image: document.createElement('img'), canvas: canvases.freeze, } inputs.freeze.addEventListener('change', e => { freeze.state = freeze.init = e.target.checked }) // Amp: for values that can range from 0 to +infinity, amp**value does the mapping. context.textAlign = 'center' context.textBaseline = 'middle' function draw() { context.clearRect(0, 0, w, h) // Get values inputs.hue.value %= 1 inputs.rotate.value %= 1 let v = values let light = percentage(polynomial_map(v.light, 2)) let contrast = percentage(polynomial_map(v.contrast, 3)) let warmth = isFirefox ? 0 : v.warmth let tint = isFirefox ? 0 : v.tint let sepia = percentage(v.sepia) let hue = 360 * v.hue + 'deg' let saturate = percentage(amp ** v.saturate) let blur = v.blur * w / 16 + 'px' let fade = v.fade let vignette = v.vignette let rotate = v.rotate * 2 * Math.PI let scale = polynomial_map(v.scale, 2) let mirror = v.mirror let move_x = v.pan * w let move_y = v.tilt * h let pillarbox = v.pillarbox * w / 2 let letterbox = v.letterbox * h / 2 let text = v.text.split('\n') // Color balance components.R.setAttribute('tableValues', polynomial_table(-warmth + tint / 2)) components.G.setAttribute('tableValues', polynomial_table(-tint)) components.B.setAttribute('tableValues', polynomial_table( warmth + tint / 2)) // CSS filters context.filter = (` brightness(${light}) contrast(${contrast}) ${'url(#filter)'.repeat(Boolean(warmth||tint))} sepia(${sepia}) hue-rotate(${hue}) saturate(${saturate}) blur(${blur}) `) // Linear transformations: rotation, scaling, translation context.translate(...center) if (rotate) context.rotate(rotate) if (scale - 1) context.scale(scale, scale) if (mirror) context.scale(-1, 1) if (move_x || move_y) context.translate(move_x, move_y) context.translate(-w / 2, -h / 2) // Apply CSS filters & linear transformations if (freeze.init) { freeze.canvas.context.drawImage(video, 0, 0, w, h) let data = freeze.canvas.element.toDataURL('image/png') freeze.image.setAttribute('src', data) freeze.init = false } else if (freeze.state) { // Draw frozen image context.drawImage(freeze.image, 0, 0, w, h) } else if (video.srcObject) { // Draw video context.drawImage(video, 0, 0, w, h) } else { // Draw preview stripes if video doesn't exist '18, 100%, 68%; -10,100%,80%; 5, 90%, 72%; 48, 100%, 75%; 36, 100%, 70%; 20, 90%, 70%' .split(';') .forEach((color, index) => { context.fillStyle = `hsl(${color})` context.fillRect(index * w / 6, 0, w / 6, h) }) } // Clear transforms & filters context.setTransform(1, 0, 0, 1, 0, 0) context.filter = 'brightness(1)' // Fade: cover the entire image with a single color if (fade) { let fade_lum = Math.sign(fade) * 100 let fade_alpha = Math.abs(fade) context.fillStyle = `hsla(0,0%,${fade_lum}%,${fade_alpha})` context.fillRect(0, 0, w, h) } // Vignette: cover the edges of the image with a single color if (vignette) { let vignette_lum = Math.sign(vignette) * 100 let vignette_alpha = Math.abs(vignette) let vignette_gradient = context.createRadialGradient( ...center, 0, ...center, Math.sqrt((w / 2) ** 2 + (h / 2) ** 2) ) vignette_gradient.addColorStop(0, `hsla(0,0%,${vignette_lum}%,0`) vignette_gradient.addColorStop(1, `hsla(0,0%,${vignette_lum}%,${vignette_alpha}`) context.fillStyle = vignette_gradient context.fillRect(0, 0, w, h) } // Pillarbox: crop width if (pillarbox) { context.clearRect(0, 0, pillarbox, h) context.clearRect(w, 0, -pillarbox, h) } // Letterbox: crop height if (letterbox) { context.clearRect(0, 0, w, letterbox) context.clearRect(0, h, w, -letterbox) } // Text: if (text) { // Find out the font size that just fits const vw = 0.9 * (w - 2 * pillarbox) const vh = 0.9 * (h - 2 * letterbox) context.font = `bold ${vw}px ${display_fonts}` let char_metrics = context.measureText('0') let line_height = char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent let text_width = text.reduce( (max_width, current_line) => Math.max( max_width, context.measureText(current_line).width ), 0 // Accumulator starts at 0 ) const font_size = Math.min(vw ** 2 / text_width, vh ** 2 / line_height / text.length) // Found the font size. Time to draw! context.font = `bold ${font_size}px ${display_fonts}` char_metrics = context.measureText('0') line_height = 1.5 * (char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent) context.lineWidth = font_size / 8 context.strokeStyle = 'black' context.fillStyle = 'white' text.forEach((line, index) => { let x = center[0] let y = center[1] + line_height * (index - text.length / 2 + 0.5) context.strokeText(line, x, y) context.fillText(line, x, y) }) } canvases.display.context.clearRect(0, 0, w, h) canvases.display.context.drawImage(canvases.buffer.element, 0, 0) } clearInterval(task) task = setInterval(draw, 33) const new_stream = canvases.display.element.captureStream(30) new_stream.addEventListener('inactive', () => { old_stream.getTracks().forEach(track => { track.stop() }) canvases.display.context.clearRect(0, 0, w, h) video.srcObject = null }) return new_stream } } MediaDevices.prototype.old_getUserMedia = MediaDevices.prototype.getUserMedia MediaDevices.prototype.getUserMedia = async constraints => (constraints && constraints.video && !constraints.audio) ? new mercator_studio_MediaStream(await navigator.mediaDevices.old_getUserMedia(constraints)) : navigator.mediaDevices.old_getUserMedia(constraints) })()