0% found this document useful (0 votes)
27 views19 pages

Mercator Studio For Google Meetv2.2.1

This document contains the code for a user script called Mercator Studio for Google Meet. It allows changing a user's appearance on Google Meet by creating a sidebar interface. The sidebar contains controls for adjusting effects like lighting, saturation, blur, and more using HTML, CSS, and JavaScript. It is open source under the MIT license.

Uploaded by

occultistpowers
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
27 views19 pages

Mercator Studio For Google Meetv2.2.1

This document contains the code for a user script called Mercator Studio for Google Meet. It allows changing a user's appearance on Google Meet by creating a sidebar interface. The sidebar contains controls for adjusting effects like lighting, saturation, blur, and more using HTML, CSS, and JavaScript. It is open source under the MIT license.

Uploaded by

occultistpowers
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 19

// ==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
// @downloadURL https://update.greasyfork.org/scripts/406944/Mercator%20Studio
%20for%20Google%20Meet.user.js
// @updateURL https://update.greasyfork.org/scripts/406944/Mercator%20Studio%20for
%20Google%20Meet.meta.js
// ==/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)
})()

You might also like