// ==UserScript==
// @name Mercator Studio for Google Meet
// @version 1.13.1
// @description Change how you look on Google Meet.
// @author Xing <dev@x-ing.space> (https://x-ing.space)
// @copyright 2020, 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')
const shadow = host.attachShadow({mode: 'open'})
// Create form
const main = document.createElement('main')
main.addEventListener('click',event=>{
if (
!main.classList.contains('focus')
&& event.target !== collapse
) {
main.classList.add('focus')
}
})
const collapse = document.createElement('button')
collapse.textContent = '↑ collapse ↑'
collapse.id = 'collapse'
collapse.addEventListener('click',()=>{
main.classList.remove('focus')
})
const form = document.createElement('form')
const style = document.createElement('style')
const font_family = `"Google Sans", Roboto, RobotDraft, Helvetica, sans-serif, serif`
style.textContent = `
* {
box-sizing: border-box;
transition: all 200ms;
}
*:not(input) {
user-select: none;
}
@media (prefers-reduced-motion) {
* {
transition: all 0s;
}
}
:focus {
outline: 0;
}
main {
z-index: 99999;
position: fixed;
left: 0;
top: 0;
width: 480px;
max-width: 100vw;
height: auto;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
background: white;
transform: translateY(calc(-100% + 3rem));
box-shadow: 0 .1rem .25rem #0004;
border-radius: 0 0 .75rem 0;
padding: 1rem 1rem 0 1rem;
overflow: hidden scroll;
font-family: ${font_family};
font-size: 1rem;
cursor: pointer;
}
button{
font-family: inherit;
font-size: .8rem;
}
main #collapse {
background: white;
cursor: pointer;
margin-bottom: .5rem;
}
main.focus {
transform: none;
border-radius: 0;
height: 100vh;
padding: 1rem;
cursor: default;
}
#previews {
margin-top: 1rem;
height: 3rem;
display: flex;
}
#previews>video,
#previews>canvas {
height: 100%;
width: 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%
);
margin-right: 1rem;
}
#previews>h1 {
flex-grow: 1;
font-size: 1rem;
font-weight: normal;
text-align: center;
color: #444;
line-height: 1.5rem;
}
:hover>#previews>h1 {
transform: translateY(.1rem); /* Tiny nudge downwards */
}
.focus>#previews>h1 {
display: none;
}
.focus>#previews {
height: auto;
}
.focus>#previews>* {
height: auto;
width: calc(50% - .5rem);
}
#presets,
label {
display: flex;
justify-content: space-between;
align-items: center;
}
#presets>* {
border: 0;
background: transparent;
flex-grow: 1;
}
#presets>:first-child {
border-radius: 100px 0 0 100px;
}
#presets>:last-child {
border-radius: 0 100px 100px 0;
}
label {
height: 2rem;
}
label>*{
width: calc(100% - 6.5rem);
}
label>*,
#collapse {
height: 1.5rem;
border-radius: 0.75rem;
border: 0.25rem solid lightgray;
}
label>:hover,
#collapse:hover {
border: 0.25rem solid gray;
}
#presets>:hover {
background: #0003;
}
#presets>:focus {
background: black;
color: white;
}
#presets:focus-within,
#collapse:focus,
label>:focus {
border-color: black;
}
textarea {
text-align: center;
font-family: inherit;
font-weight: bold;
resize: none;
line-height: 1;
}
input[type=range] {
-webkit-appearance: none;
--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;
background: white;
width: 1rem;
height: 1rem;
border: 0.25rem solid black;
border-radius: 0.5rem;
}
input[type=range]:focus::-webkit-slider-thumb {
border-color: white;
background: black;
}
input#exposure,
input#fog,
input#vignette {
--gradient: black, #8880, white
}
input#contrast {
--gradient: gray, #8880
}
input#temperature {
--gradient: #88f, #8880, #ff8
}
input#tint {
--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#x,
input#y,
input#pillarbox,
input#letterbox {
--gradient: black, white
}
`
form.append(style)
// Create inputs
const saved_values = JSON.parse(window.localStorage.getItem('mercator-studio-values'))
const inputs = Object.fromEntries(
'exposure,contrast,temperature,tint,sepia,hue,saturate,blur,fog,vignette,rotate,scale,x,y,pillarbox,letterbox,text'
.split(',')
.map( key => {
let input
if ( key == 'text' ) {
input = document.createElement('textarea')
input.placeholder = '🌈 Write text here 🌦️'
} else {
input = document.createElement('input')
input.type = 'range'
input.min = [
'blur',
'sepia',
'scale',
'pillarbox',
'letterbox'
].includes(key) ? 0 : -1
input.max = 1
input.step = 0.00001
input.value = 0
}
input.classList.add('input')
if ( saved_values ) input.value = saved_values[key]
if (
!['temperature','tint'].includes(key)
|| !navigator.userAgent.includes('Firefox')
) {
// Disable the SVG filters for Firefox
let label = document.createElement('label')
label.textContent = input.id = key
form.append(label)
label.append(input)
}
return [key,input]
})
)
const values = Object.fromEntries(
Object.entries(inputs)
.map(entry=>[
entry[0],
entry[1].valueAsNumber || entry[1].value
])
)
function update_values (input,value) {
values[input.id] = input.value = value
window.localStorage.setItem('mercator-studio-values',JSON.stringify(values))
}
// Scroll to change values
form.addEventListener('wheel',event=>{
if ( event.target.type !== 'range' ) return
event.preventDefault()
const slider = event.target
const width = slider.getBoundingClientRect().width
const dx = -event.deltaX
const dy = event.deltaY
const ratio = ( Math.abs(dx) > Math.abs(dy) ? dx : dy ) / width
const range = slider.max - slider.min
update_values( slider, slider.valueAsNumber + ratio*range )
})
// Right click to individually reset
form.addEventListener('contextmenu',event=>{
if ( event.target.type !== 'range' ) return
event.preventDefault()
update_values( event.target, 0 )
})
form.addEventListener('input',event=>{
const input = event.target
update_values( input, input.id === 'text' ? input.value.toString() : input.valueAsNumber )
})
const presets_label = document.createElement('label')
const presets_collection = document.createElement('div')
presets_collection.id = 'presets'
const presets = 'reset,concorde,mono,stucco,matcha,deepfry'
.split(',')
.map(key=>{
let preset = document.createElement('button')
preset.textContent = preset.id = key
return preset
})
presets_label.textContent = 'presets'
presets_collection.append(...presets)
presets_label.append(presets_collection)
function get_preset_values ( preset_name ) {
switch(preset_name){
case 'concorde': return {
saturate: 0.1,
contrast: 0.1,
temperature: -0.4,
tint: 0.2,
}
case 'mono': return {
saturate: -1,
contrast: -0.1,
exposure: 0.1,
vignette: -0.5,
}
case 'stucco': return {
contrast: -0.1,
temperature: -0.2,
tint: 0.2,
sepia: 0.2,
saturate: 0.25,
fog: 0.1,
}
case 'matcha': return {
exposure: 0.1,
tint: -0.75,
sepia: 1,
hue: 0.2,
vignette: 0.3,
fog: 0.3,
}
case 'deepfry': return {
contrast: 1,
saturate: 1,
}
}
}
presets_label.addEventListener('click',event=>{
// Cancel refresh
event.preventDefault()
const preset_values = get_preset_values(event.target.id)
// Reset all
Object.values(inputs).forEach(input=>{
update_values(input, input.id === 'text' ? '' : preset_values ? preset_values[input.id] || 0 : 0)
})
})
// 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.toUpperCase())
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)
const previews = document.createElement('div')
previews.id = 'previews'
// Create preview video
const video = document.createElement('video')
video.setAttribute('playsinline','')
video.setAttribute('autoplay','')
video.setAttribute('muted','')
// Create canvas
const canvas = document.createElement('canvas')
// Create title
const h1 = document.createElement('h1')
h1.textContent = '↓ Mercator Studio ↓'
previews.append(video,canvas,h1)
// Add UI to page
form.append(presets_label)
main.append(collapse,form,previews)
shadow.append(main,svg)
document.body.append(host)
function polynomial_map(value,degree) {
return (value+1)**degree
}
function polynomial_table(factor){
return Array(32).fill(0).map(
(value,index)=>
Math.pow(index/31,2**factor)
).join(' ')
}
function percentage(value) {
return value*100+'%'
}
function signed_pow(value,power){
return Math.sign(value)*Math.abs(value)**power
}
const amp = 8
// Background Blur for Google Meet does this (hello@brownfoxlabs.com)
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]
canvas.width = w
canvas.height = h
const context = canvas.getContext('2d')
// Amp: for values that can range from 0 to +infinity, amp**value does the mapping.
let time = video.currentTime
context.textAlign = 'center'
context.textBaseline = 'middle'
function draw(){
// Avoid drawing the frame frame over and over, unless it's the preview stripes
if ( !video.srcObject || time != video.currentTime) {
time = video.currentTime
context.clearRect(0,0,w,h)
// Get values
inputs.hue.value %= 1
inputs.rotate.value %= 1
let v = values
let exposure = percentage(polynomial_map(v.exposure,2))
let contrast = percentage(polynomial_map(v.contrast,3))
let temperature = v.temperature
let tint = 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 fog = v.fog
let vignette = v.vignette
let rotate = v.rotate*2*Math.PI
let scale = polynomial_map(v.scale,2)
let move_x = v.x*w
let move_y = v.y*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(-temperature+tint/2))
components.g.setAttribute('tableValues',polynomial_table(-tint))
components.b.setAttribute('tableValues',polynomial_table( temperature+tint/2))
// CSS filters
context.filter = (`
brightness(${exposure})
contrast(${contrast})
${'url(#filter)'.repeat(Boolean(temperature||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 ( move_x || move_y ) context.translate(move_x,move_y)
context.translate(-w/2,-h/2)
// Apply CSS filters & linear transformations
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)'
// Fog: cover the entire image with a single color
if ( fog ) {
let fog_lum = Math.sign(fog)*100
let fog_alpha = Math.abs(fog)
context.fillStyle = `hsla(0,0%,${fog_lum}%,${fog_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 ${font_family}`
let char_metrics = context.measureText('0')
let char_width = char_metrics.width
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 ${font_family}`
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)
})
}
}
// Recursive call
requestAnimationFrame(draw)
}
draw()
const new_stream = canvas.captureStream(30)
new_stream.addEventListener('inactive',() => {
old_stream.getTracks().forEach(track => {
track.stop()
})
context.clearRect(0,0,w,h)
video.srcObject = null
})
return new_stream
}
}
async function mercator_studio_getUserMedia ( constraints ) {
if (constraints && constraints.video && !constraints.audio ) {
return new mercator_studio_MediaStream(await navigator.mediaDevices.old_getUserMedia(constraints))
} else {
return navigator.mediaDevices.old_getUserMedia(constraints)
}
}
MediaDevices.prototype.old_getUserMedia = MediaDevices.prototype.getUserMedia
MediaDevices.prototype.getUserMedia = mercator_studio_getUserMedia
} ) ()