├── icon.png ├── makefile ├── dist ├── extension.zip └── bookmarklet.js ├── pics ├── 1.10.0-promo-cards.png ├── 1.9.0-promo-sliders.jpg └── 1.9.0-promo-stacked.jpg ├── src ├── injector.js ├── build.sh ├── manifest.json ├── icon.svg ├── template.html └── script.js ├── PRIVACY ├── LICENSE ├── README └── index.html /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xingyzt/mercator/HEAD/icon.png -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | build: 2 | sh src/build.sh 3 | host: 4 | python3 -m http.server 5 | -------------------------------------------------------------------------------- /dist/extension.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xingyzt/mercator/HEAD/dist/extension.zip -------------------------------------------------------------------------------- /pics/1.10.0-promo-cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xingyzt/mercator/HEAD/pics/1.10.0-promo-cards.png -------------------------------------------------------------------------------- /pics/1.9.0-promo-sliders.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xingyzt/mercator/HEAD/pics/1.9.0-promo-sliders.jpg -------------------------------------------------------------------------------- /pics/1.9.0-promo-stacked.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xingyzt/mercator/HEAD/pics/1.9.0-promo-stacked.jpg -------------------------------------------------------------------------------- /src/injector.js: -------------------------------------------------------------------------------- 1 | const host = document.createElement('aside') 2 | const shadow = host.attachShadow({mode:'closed'}) 3 | const script = document.createElement('script') 4 | script.src = chrome.runtime.getURL('script.js') 5 | shadow.append(script) 6 | document.body.append(host) 7 | // Thanks to Rob Wu (github.com/Rob--W) for fixing this 8 | -------------------------------------------------------------------------------- /src/build.sh: -------------------------------------------------------------------------------- 1 | zip -rX dist/extension.zip \ 2 | icon.png src/script.js src/injector.js src/manifest.json 3 | 4 | curl -X POST -s --data-urlencode 'input@src/script.js' \ 5 | https://javascript-minifier.com/raw > dist/bookmarklet.js 6 | 7 | sed -i "s/'/\`/g" dist/bookmarklet.js 8 | sed -i 's/!/(/' dist/bookmarklet.js 9 | sed -i 's/();$/)()/' dist/bookmarklet.js 10 | 11 | export BOOKMARKLET="$(cat dist/bookmarklet.js)" 12 | export README="$(cat README)" 13 | envsubst < src/template.html > index.html 14 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mercator Studio for Google Meet", 3 | "version": "2.2.1", 4 | "author": "Xing (x-ing.space)", 5 | "description": "Change how you look on Google Meet with adjustable exposure, vignette, emojis, and more!", 6 | "content_scripts": [ 7 | { 8 | "matches": ["https://meet.google.com/*"], 9 | "js": ["injector.js"] 10 | } 11 | ], 12 | "page_action": { 13 | "default_icon": { 14 | "128": "icon.png" 15 | } 16 | }, 17 | "web_accessible_resources": [ 18 | "script.js" 19 | ], 20 | "icons": { 21 | "128": "icon.png" 22 | }, 23 | "manifest_version": 2 24 | } 25 | -------------------------------------------------------------------------------- /PRIVACY: -------------------------------------------------------------------------------- 1 | "Mercator Studio for Google Meet" is a browser extension which injects a 2 | JavaScript program into Google Meet. This program applies color filters and 3 | draws shapes on your video feed. It runs entirely inside the browser, accessing 4 | no external resources. It remembers your input preferences by putting them in 5 | your browser's local storage. These preferences can be deleted by clearing your 6 | browser's site data. Only your filtered video data is transmitted to Google 7 | Meet. However, Google handles your filtered video data according to their own 8 | Terms of Service and Privacy Policy. You can stop the transmission of video 9 | data to both the extension and Google Meet by turning off the camera, for 10 | example, through the Google Meet's "disable camera" button. 11 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | Logo of Mercator Studio for Google Meet 3 | A white video recorder icon in a dark teal circle, wrapped by stripes in shades of pastel orange. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | (C) Xing Liu, 2020 19 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Xing Liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Mercator Studio gives you fine control over your appearance on Google Meet. 2 | 3 | Precisely adjust lighting and colors: 4 | · Exposure & Contrast 5 | · Temperature & Tint 6 | · Hue & Saturation 7 | · Sepia & Blur 8 | · Fade & Vignette 9 | 10 | Move the focus to where you want it: 11 | · Rotate, Scale, Mirror & Flip 12 | · Horizontal & Vertical Translate 13 | · Pillarbox & Letterbox Crop 14 | 15 | Write text & emoji in front of your face: 16 | · Auto-adjusts size to fit any length of text onto the screen. 17 | · Auto-converts \sqrt to √, \times to ×, \cdot to ·, \pm to ±, ^number to ¹², and _number to ₄₂. 18 | 19 | Somewhat nice presets: 20 | · Concorde 21 | · Mono 22 | · Matcha 23 | · Deepfry 24 | 25 | Scroll, drag, or use arrow keys on the sliders to adjust; Right click or press 0 on them to reset; And hold down Ctrl or Shift for finer steps. 26 | 27 | Ctrl + M to open/close the interface. Ctrl + Shift + M to minimize it. 28 | 29 | Translated for português, español, italiano, français, & 中文. 30 | 31 | Changelog: 32 | · 2.2 Translate from EN into PT, ES, IT, FR, and ZH. 33 | · 2.1 Improve keyboard navigation. 34 | · 2.0 Redesign for Google Meet’s new look. 35 | · 1.19 Add mirroring; Dark mode support. 36 | · 1.18 Make textbox auto-resize; Ctrl or Shift for finer steps. 37 | · 1.17 Fix flickering and window-focus issues. 38 | · 1.16 Add freeze feature (thanks @napsav). 39 | · 1.15 Add toggle to super tiny mode. 40 | · 1.14 Add math auto-convert. 41 | · 1.13 Preserve values across sessions. 42 | · 1.12 Luminance-preserving temperature & tint. 43 | · 1.11 Multiline text input; Rebranded as Mercator Studio for Google Meet. 44 | · 1.10 Sync camera; Capture scroll; Right-click reset; Firefox support. 45 | · 1.9 Add text & emoji input. 46 | · 1.8 Add presets; Matched UI with material design. 47 | · 1.7 Add color balance tools and refined UI. 48 | · 1.6 Add fog. 49 | · 1.5 Add vignettes. 50 | · 1.4 Converted to Chrome extension. 51 | · 1.3 Fix the blur slider's range. 52 | · 1.2 Add cropping. 53 | · 1.1 Add a way to reset everything. 54 | · 1.0 Hello world! 55 | 56 | Source code: https://github.com/FlyOrBoom/mercator. 57 | 58 | Available for other browsers: https://x-ing.space/mercator. 59 | Unfortunately, temperature & tint filters don't work in Firefox. 60 | 61 | (C) Xing Liu 2020–2021, MIT License. 62 | 63 | --- 64 | 65 | This project is no longer in active development. As the pandemic subsides in my area, so has my use of Google Meet, and with it Mercator Studio. At the same time, I think its development has reached a calm plateau. 66 | 67 | My fingers are crossed that Mercator will continue working for those still learning or working from home, but I will no longer add new features or patch minor bugs. Though, if there are any changes to Google Meet that breaks the core functionality of Mercator Studio, I will strive to get them fixed. 68 | 69 | Until then, thank you for all of your support. I know this year must have been awful for a lot of you. I hope my extension helped alleviate some of your camera anxieties, or caused some laughter as you turned your skin blue. 70 | 71 | Best regards, 72 | Xing Liu 73 | 2021-06-28 74 | 75 | --- 76 | 77 | Check out other things I’ve made at https://x-ing.space. 78 | -------------------------------------------------------------------------------- /dist/bookmarklet.js: -------------------------------------------------------------------------------- 1 | (async function(){"use strict";const t=document.createElement("aside");t.style.position="absolute",t.style.zIndex=10,t.style.pointerEvents="none";const e=t.attachShadow({mode:"open"}),n=navigator.userAgent.includes("Firefox"),i=document.createElement("main"),r=document.createElement("style"),a="Roboto, RobotDraft, Helvetica, sans-serif, serif",s=`"Google Sans", `+a;r.textContent=`\na, button {\n\tall: unset;\n\tcursor: pointer;\n\ttext-align: center;\n}\nmain, main * {\n\tbox-sizing: border-box;\n\ttransition-duration: 200ms;\n\ttransition-property: opacity, background, transform, padding, border-radius, border-color;\n\tcolor: inherit;\n\tfont-family: inherit;\n\tfont-size: inherit;\n\tfont-weight: inherit;\n}\n@media (prefers-reduced-motion) {\n\tmain, main * {\n\t\ttransition-duration: 0s;\n\t}\n}\n:not(input) {\n\tuser-select: none;\n}\n:focus {\n\toutline: 0;\n}\n\n/* -- */\n\nmain {\n\t--bg: #3C4042;\n\t--bg-x: #434649;\n\t--bg-xx: #505457;\n\t--txt: white;\t\n\n\tfont-family: ${s};\n\tfont-size: 0.9rem;\n\twidth: 25rem;\n\tmax-width: 100vw;\n\theight: 100vh;\n\tposition: fixed;\n\tbottom: 0;\n\tleft: 0;\n\tpadding: 0.5rem;\n\tdisplay: flex;\n\tflex-direction: column-reverse;\n\toverflow: hidden;\n\tpointer-events: none;\n\tcolor: var(--txt);\n}\n#fields,\n#bar,\n#labels > * {\n\tborder-radius: .5rem;\n\tbox-shadow: 0 .1rem .25rem #0004;\n\tpointer-events: all;\n}\n:not(.edit) > #fields {\n\tdisplay: none;\n}\n:not(.edit) > #bar{\n\tborder-radius: 1.5rem;\n\tflex-basis: 4rem;\n}\n#text:hover, #text:focus,\n#presets:hover,\n#bar > :hover, #bar > :focus,\n#tips > * {\n\tbackground: var(--bg-x);\n}\n#text:hover:focus,\n#presets:hover,\n#bar > :hover:focus {\n\tbackground: var(--bg-xx);\n}\n\n/* -- */\n\n#tips {\n\tposition: relative;\n\tfont-family: ${a};\n\tfont-size: 0.8rem;\n\tline-height: 1rem;\n\tz-index: 10;\n}\n#tips > * {\n\tdisplay: block;\n\tposition: absolute;\n\tbottom: 0rem;\n\theight: 1.5rem;\n\tpadding: 0.25rem;\n\tborder-radius: 0.25rem;\n}\n#tips > :not(.show) {\n\topacity: 0;\n}\n#tips > [for=minimize] {\n\tleft: 0;\n}\n#tips > [for=previews] {\n\tleft: 50%;\n\ttransform: translateX(-50%);\n}\n#tips > [for=donate] {\n\tright: 0;\n}\n.edit > #tips > * {\n\ttop: 1rem;\n}\n\n/* -- */\n\n#bar {\n\tmargin-top: 0.5rem;\n\toverflow: hidden;\n\tflex: 0 0 auto;\n\tdisplay: flex;\n}\n.minimize > #bar {\n\twidth: 1rem;\n}\n#bar > * {\n\tbackground: var(--bg);\n}\n#bar > :not(#previews) {\n\tfont-size: 0.5rem;\n\tflex: 0 0 1.5rem;\n\twidth: var(--radius);\n\ttext-align: center;\n\tline-height: 4rem;\n\theight: 100%;\n\toverflow-wrap: anywhere;\n}\n.edit > #bar > :not(#previews),\n.edit > #bar > #previews > h2,\n.minimize #bar :not(#minimize) {\n\tdisplay: none;\n}\n:not(.minimize) > #bar > #minimize:hover {\n\tpadding-right: 2px;\n}\n.minimize > #bar:hover > #minimize,\n#donate:hover {\n\tpadding-left: 2px;\n}\n.minimize > #bar > #minimize{\n\tflex-basis: 1rem;\n}\n#previews {\n\tflex: 1 0 0;\n\twidth: 0;\n\tdisplay: flex;\n}\n#previews > :not(h2) {\n\twidth: auto;\n\theight: auto;\n\tbackground-image: linear-gradient(90deg,\n\t\thsl( 18, 100%, 68%) 16.7%,\thsl(-10, 100%, 80%) 16.7%,\n\t\thsl(-10, 100%, 80%) 33.3%,\thsl(5,90%, 72%) 33.3%,\n\t\thsl(5,90%, 72%) 50%,\thsl( 48, 100%, 75%) 50%,\n\t\thsl( 48, 100%, 75%) 66.7%,\thsl( 36, 100%, 70%) 66.7%,\n\t\thsl( 36, 100%, 70%) 83.3%,\thsl( 20,90%, 70%) 83.3%\n\t);\n}\n.edit > #bar > #previews > :not(h2) {\n\theight: auto;\n\tmax-width: 50%;\n\tobject-fit: contain;\n}\n#previews > h2 {\n\tflex-grow: 1;\n\tfont-size: .9rem;\n\tline-height: 1.4;\n\tdisplay: flex;\n\ttext-align: center;\n\talign-items: center;\n\tjustify-content: center;\n}\n#previews:hover > h2 {\n\ttransform: translateY(-2px);\n}\n\n/* -- */\n\n#fields {\n\tdisplay: flex;\n\tflex-direction: column;\n\toverflow: hidden scroll;\n\tpadding: 1rem;\n\tflex: 0 1 auto;\n\tbackground: var(--bg);\n}\n#presets,\n#fields > label {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n}\n#fields > label+label {\n\tmargin-top: 0.5rem;\n}\n#fields > label:focus-within{\n\tfont-weight: bold;\n}\n#fields > label > * {\n\twidth: calc(100% - 4.5rem);\n\theight: 1rem;\n\tborder-radius: 0.5rem;\n\tborder: 0.15rem solid var(--bg-x);\n\tfont-size: 0.8rem;\n}\n#presets:focus-within,\n#fields > label > :focus,\n#fields > label > :hover {\n\tborder-color: var(--txt);\n}\n#fields > label > #presets {\n\toverflow: hidden;\n\theight: 1.5rem;\n}\n#presets > * {\n\tflex-grow: 1;\n\theight: 100%;\n\tfont-weight: normal;\n}\n#presets > :hover {\n\tbackground: var(--bg);\n}\n#presets > :focus {\n\tbackground: var(--txt);\n\tcolor: var(--bg);\n}\n#fields > label > #text {\n\ttext-align: center;\n\tfont-weight: bold;\n\tresize: none;\n\tline-height: 1.1;\n\toverflow: hidden scroll;\n\tbackground: var(--bg);\n\theight: auto;\n}\n#text::placeholder {\n\tcolor: inherit;\n}\n#text::selection {\n\tcolor: var(--bg);\n\tbackground: var(--txt);\n}\ninput[type=checkbox] {\n\tcursor: pointer;\n}\ninput[type=range] {\n\t-webkit-appearance: none;\n\tcursor: ew-resize;\n\t--gradient: transparent, transparent;\n\t--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%);\n\tbackground: linear-gradient(90deg, var(--gradient)), linear-gradient(90deg, var(--rainbow));\n}\ninput[type=range]::-webkit-slider-thumb {\n\t-webkit-appearance: none;\n\ttransition: inherit;\n\tbackground: var(--bg);\n\twidth: 1rem;\n\theight: 1rem;\n\tborder: 0.1rem solid var(--txt);\n\ttransform: scale(1.5);\n\tborder-radius: 100%;\n}\ninput[type=range]:hover::-webkit-slider-thumb {\n\tbackground: var(--bg-x);\n}\ninput[type=range]:focus::-webkit-slider-thumb {\n\tborder-color: var(--bg);\n\tbackground: var(--txt);\n}\ninput[type=range]::-moz-range-thumb {\n\ttransition: inherit;\n\tbackground: var(--bg);\n\twidth: 1rem;\n\theight: 1rem;\n\tborder: 0.1rem solid var(--txt);\n\ttransform: scale(1.5);\n\tborder-radius: 100%;\n\tbox-sizing: border-box;\n}\ninput[type=range]:hover::-moz-range-thumb {\n\tbackground: var(--bg-x);\n}\ninput[type=range]:focus::-moz-range-thumb {\n\tborder-color: var(--bg);\n\tbackground: var(--txt);\n}\ninput#light,\ninput#fade,\ninput#vignette {\n\t--gradient: black, #8880, white\n}\ninput#contrast {\n\t--gradient: gray, #8880\n}\ninput#warmth,\ninput#tilt {\n\t--gradient: #88f, #8880, #ff8\n}\ninput#tint,\ninput#pan {\n\t--gradient: #f8f, #8880, #8f8\n}\ninput#sepia {\n\t--gradient: #8880, #aa8\n}\ninput#hue,\ninput#rotate {\n\t--gradient: var(--rainbow), var(--rainbow)\n}\ninput#saturate {\n\t--gradient: gray, #8880 50%, blue, magenta\n}\ninput#blur {\n\t--gradient: #8880, gray\n}\ninput#scale,\ninput#pillarbox,\ninput#letterbox {\n\t--gradient: black, white\n}\n`;const o={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:"捐款给作者"}};i.lang=["en","es","fr","it","pt","zh"].find(t=>t===navigator.language.split("-")[0])||"en";for(const t in o)o[t]=o[t][i.lang];const l=document.createElement("section");l.id="fields";const c={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"},d={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:!1,freeze:!1,presets:"reset"},p=Object.fromEntries(Object.entries(JSON.parse(window.localStorage.getItem("mercator-studio-values-20"))||{}).filter(([t])=>t in d)),u={reset:{},concorde:{contrast:.1,warmth:-.25,tint:-.05,saturate:.2},mono:{light:.1,contrast:-.1,sepia:.8,saturate:-1,vignette:-.5},matcha:{light:.1,tint:-.75,sepia:1,hue:.2,vignette:.3,fade:.3},deepfry:{contrast:1,saturate:.5}},m={...d,...p,freeze:!1},h=Object.fromEntries(Object.entries(m).map(([t,e])=>{let i;const r=c[t];switch(r){case"textarea":(i=document.createElement("textarea")).rows=3,i.placeholder=`\n🌈 ${o.text_tip} 🌦️`,i.addEventListener("input",()=>f(i,i.value.replace(/--/g,"―").replace(/\\sqrt/g,"√").replace(/\\pm/g,"±").replace(/\\times/g,"×").replace(/\\cdot/g,"·").replace(/\\over/g,"∕").replace(/(\^|\_)(\d+)/g,(t,e,n)=>n.split("").map(t=>"₀₁₂₃₄₅₆₇₈₉⁰¹²³⁴⁵⁶⁷⁸⁹"[10*("^"===e)+parseInt(t)]).join(""))));break;case"checkbox":(i=document.createElement("input")).type="checkbox",i.addEventListener("change",()=>f(i,i.checked));break;case"radio":(i=document.createElement("label")).append(...Object.keys(u).map(t=>{const e=document.createElement("button"),n="reset"===t;return e.textContent=n?o.reset:t,e.setAttribute("aria-label",o.preset+e.textContent),e.addEventListener("click",e=>{e.preventDefault(),Object.entries({...d,...u[t]}).filter(([t])=>!["radio","checkbox"].includes(c[t])).forEach(([t,e])=>f(h[t],e))}),e}));break;default:(i=document.createElement("input")).type="range",i.min=("range_positive"===r)-1,i.max=1;const t=i.max-i.min;i.step=t/32,i.addEventListener("keydown",({code:e,ctrlKey:n,shiftKey:r})=>{"Digit0"===e&&g(i),i.step=t/(r?512:n?128:32)}),i.addEventListener("keyup",()=>i.step=t/32),i.addEventListener("input",()=>{i.focus(),f(i,i.valueAsNumber)}),i.addEventListener("wheel",t=>{t.preventDefault(),i.focus();const e=i.getBoundingClientRect().width,n=-t.deltaX,r=t.deltaY,a=(Math.abs(n)>Math.abs(r)?n:r)/e,s=i.max-i.min,o=i.valueAsNumber+a*s,l=Math.min(Math.max(o,i.min),i.max),c=Math.round(l/i.step)*i.step;f(i,c)}),i.addEventListener("contextmenu",t=>{t.preventDefault(),g(i)})}if(f(i,e),i.id=t,!n||!["warmth","tint"].includes(t)){let e=document.createElement("label");e.textContent=o[t],e.append(i),l.append(e)}return[t,i]}));function f(t,e){m[t.id]=t.value=t.checked=e,window.localStorage.setItem("mercator-studio-values-20",JSON.stringify(m))}function g(t){f(t,d[t.id])}const b="http://www.w3.org/2000/svg",v=document.createElementNS(b,"svg"),x=document.createElementNS(b,"filter");x.id="filter";const w=document.createElementNS(b,"feComponentTransfer"),z=Object.fromEntries(["R","G","B"].map(t=>{const e=document.createElementNS(b,"feFunc"+t);return e.setAttribute("type","table"),e.setAttribute("tableValues","0 1"),[t,e]}));w.append(...Object.values(z)),x.append(w),v.append(x);const y=document.createElement("label");y.htmlFor="minimize",y.dataset.off=`${o.minimize_tip}${o.previews_tip} (ctrl + shift + m)`,y.dataset.on=`${o.open_tip}${o.previews_tip} (ctrl + shift + m)`,y.textContent=y.dataset.off;const E=document.createElement("label");E.htmlFor="previews",E.dataset.off=`${o.open_tip}${o.studio_tip} (ctrl + m)`,E.dataset.on=`${o.close_tip}${o.studio_tip} (ctrl + m)`,E.textContent=E.dataset.off;const k=document.createElement("label");k.htmlFor="donate",k.textContent=o.donate_tip;const L=document.createElement("section");L.id="tips",L.append(y,E,k);const _=()=>{L.querySelectorAll(".show").forEach(t=>t.classList.remove("show"));const t=L.querySelector(".hover")||L.querySelector(".focus");t&&t.classList.add("show")},M=(t,e)=>{t.addEventListener("mouseenter",()=>{e.classList.add("hover"),_()}),t.addEventListener("mouseleave",()=>{e.classList.remove("hover"),_()}),t.addEventListener("focus",()=>{e.classList.add("focus"),_()}),t.addEventListener("blur",()=>{e.classList.remove("focus"),_()})},$=document.createElement("section");$.id="bar";const S=document.createElement("button");S.id="minimize",S.textContent="◀";const A=()=>{i.classList.remove("edit"),i.classList.toggle("minimize"),S.focus();const t=i.classList.contains("minimize");S.textContent=t?"▶":"◀",y.textContent=y.dataset[t?"on":"off"],y.classList.remove("focus"),_()};S.addEventListener("click",A),M(S,y);const j=document.createElement("a");j.id="donate",j.href="https://ko-fi.com/xingyzt",j.target="_blank",j.textContent="🤍",j.setAttribute("aria-label",o.donate_tip),M(j,k);const C=document.createElement("button");C.id="previews";const O=()=>{i.classList.remove("minimize"),i.classList.toggle("edit"),C.focus();const t=i.classList.contains("edit");t?Object.values(h)[0].focus():C.focus(),E.textContent=E.dataset[t?"on":"off"],E.classList.remove("focus"),_()};C.addEventListener("click",O),M(C,E),window.addEventListener("keydown",t=>{"KeyM"==t.code&&t.ctrlKey&&(t.preventDefault(),t.shiftKey?A():O())});const D=document.createElement("video");D.setAttribute("playsinline",""),D.setAttribute("autoplay",""),D.setAttribute("muted","");const R=Object.fromEntries(["buffer","freeze","display"].map(t=>{const e=document.createElement("canvas"),n=e.getContext("2d");return[t,{element:e,context:n}]})),B=document.createElement("h2");B.id="title",B.innerText="Mercator\nStudio",C.append(D,B,R.buffer.element),$.append(S,C,j),i.append($,L,l),e.append(i,r,v),document.body.append(t);const I=(t,e)=>(t+1)**e,T=(t,e=32)=>Array(e).fill(0).map((n,i)=>Math.pow(i/(e-1),2**t)).join(" "),q=t=>100*t+"%",F=8;let N=0;const U={state:!1,init:!1,image:document.createElement("img"),canvas:R.freeze};function V(t,e){const i=[t/2,e/2],r=[0,0,t,e],{context:a}=R.buffer;a.clearRect(...r),h.hue.value%=1,h.rotate.value%=1;let o=m,l=q(I(o.light,2)),c=q(I(o.contrast,3)),d=n?0:o.warmth,p=n?0:o.tint,u=q(o.sepia),f=360*o.hue+"deg",g=q(F**o.saturate),b=o.blur*t/16+"px",v=o.fade,x=o.vignette,w=2*o.rotate*Math.PI,y=I(o.scale,2),E=o.mirror,k=o.pan*t,L=o.tilt*e,_=o.pillarbox*t/2,M=o.letterbox*e/2,$=o.text.split("\n");if(z.R.setAttribute("tableValues",T(p/2-d)),z.G.setAttribute("tableValues",T(-p)),z.B.setAttribute("tableValues",T(d+p/2)),a.filter=`\n\t\t\tbrightness(${l})\n\t\t\tcontrast(${c})\n\t\t\t${"url(#filter)".repeat(Boolean(d||p))}\n\t\t\tsepia(${u})\n\t\t\thue-rotate(${f})\n\t\t\tsaturate(${g})\n\t\t\tblur(${b})\n\t\t`,a.translate(...i),w&&a.rotate(w),y-1&&a.scale(y,y),E&&a.scale(-1,1),(k||L)&&a.translate(k,-L),a.translate(...i.map(t=>-t)),U.init){U.canvas.context.drawImage(D,...r);let t=U.canvas.element.toDataURL("image/png");U.image.setAttribute("src",t),U.init=!1}else U.state?a.drawImage(U.image,...r):D.srcObject?a.drawImage(D,...r):"18, 100%, 68%; -10,100%,80%; 5, 90%, 72%; 48, 100%, 75%; 36, 100%, 70%; 20, 90%, 70%".split(";").forEach((n,i)=>{a.fillStyle=`hsl(${n})`,a.fillRect(i*t/6,0,t/6,e)});if(a.setTransform(1,0,0,1,0,0),a.filter="brightness(1)",v){let t=100*Math.sign(v),e=Math.abs(v);a.fillStyle=`hsla(0,0%,${t}%,${e})`,a.fillRect(...r)}if(x){let t=100*Math.sign(x),e=Math.abs(x),n=a.createRadialGradient(...i,0,...i,i.reduce((t,e)=>Math.sqrt(t**2+e**2)));n.addColorStop(0,`hsla(0,0%,${t}%,0`),n.addColorStop(1,`hsla(0,0%,${t}%,${e}`),a.fillStyle=n,a.fillRect(...r)}if(_&&(a.clearRect(0,0,_,e),a.clearRect(t,0,-_,e)),M&&(a.clearRect(0,0,t,M),a.clearRect(0,e,t,-M)),$){const n=.9*(t-2*_),r=.9*(e-2*M),o=$.length;a.font=`bold ${n}px ${s}`,a.textAlign="center",a.textBaseline="middle";let l=a.measureText("0"),c=l.actualBoundingBoxAscent+l.actualBoundingBoxDescent,d=$.reduce((t,e)=>Math.max(t,a.measureText(e).width),0);const p=Math.min(n**2/d,r**2/c/o);a.font=`bold ${p}px ${s}`,l=a.measureText("0"),c=1.5*(l.actualBoundingBoxAscent+l.actualBoundingBoxDescent),a.lineWidth=p/8,a.strokeStyle="black",a.fillStyle="white",$.forEach((t,e)=>{let n=[...i];n[1]+=c*(e-o/2+.5),a.strokeText(t,...n),a.fillText(t,...n)})}R.display.context.clearRect(...r),R.display.context.drawImage(R.buffer.element,0,0)}h.freeze.addEventListener("change",()=>{U.state=U.init=h.freeze.checked});class K extends MediaStream{constructor(t){super(t),D.srcObject=t;const{width:e,height:n}=t.getVideoTracks()[0].getSettings();Object.values(R).forEach(t=>{t.element.width=e,t.element.height=n}),clearInterval(N);N=setInterval(V,1e3/30,e,n);const i=R.display.element.captureStream(30);return i.addEventListener("inactive",()=>{t.getTracks().forEach(t=>t.stop()),R.display.context.clearRect(...fill),D.srcObject=null}),i}}MediaDevices.prototype.old_getUserMedia=MediaDevices.prototype.getUserMedia,MediaDevices.prototype.getUserMedia=(async t=>t&&t.video&&!t.audio?new K(await navigator.mediaDevices.old_getUserMedia(t)):navigator.mediaDevices.old_getUserMedia(t))})() -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mercator Studio 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 98 | 99 | 100 |
101 | 102 | Logo of Mercator Studio for Google Meet 103 | A white video recorder icon in a dark teal circle, wrapped by stripes in shades of pastel orange. 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | (C) Xing Liu, 2020 119 | 120 |

Mercator Studio for Google Meet

121 |

A project by Xing

122 |
123 | 124 | 125 | Chrome Web Store 126 | 127 | 128 | 129 | Firefox Addons 130 | 131 | 132 | 133 | Greasyfork Scripts 134 | 135 | 136 | 137 | 138 | 139 |
140 | 141 | 142 |
143 | $README 144 |
145 |
146 | 147 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /src/script.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Mercator Studio for Google Meet 3 | // @version 2.2.1 4 | // @description Change how you look on Google Meet. 5 | // @author Xing (https://x-ing.space) 6 | // @copyright 2020-2021, Xing (https://x-ing.space) 7 | // @license MIT License; https://x-ing.space/mercator/LICENSE 8 | // @namespace https://x-ing.space 9 | // @homepageURL https://x-ing.space/mercator 10 | // @icon https://x-ing.space/mercator/icon.png 11 | // @match https://meet.google.com/* 12 | // @grant none 13 | // ==/UserScript== 14 | (async function mercator_studio() { 15 | 'use strict' 16 | 17 | // Create shadow root 18 | 19 | const host = document.createElement('aside') 20 | host.style.position = 'absolute' 21 | host.style.zIndex = 10 22 | host.style.pointerEvents = 'none' 23 | const shadow = host.attachShadow({ mode: 'open' }) 24 | 25 | const isFirefox = navigator.userAgent.includes('Firefox') 26 | 27 | // Create form 28 | 29 | const main = document.createElement('main') 30 | const style = document.createElement('style') 31 | const body_fonts = 'Roboto, RobotDraft, Helvetica, sans-serif, serif' 32 | const display_fonts = '"Google Sans", ' + body_fonts 33 | style.textContent = ` 34 | a, button { 35 | all: unset; 36 | cursor: pointer; 37 | text-align: center; 38 | } 39 | main, main * { 40 | box-sizing: border-box; 41 | transition-duration: 200ms; 42 | transition-property: opacity, background, transform, padding, border-radius, border-color; 43 | color: inherit; 44 | font-family: inherit; 45 | font-size: inherit; 46 | font-weight: inherit; 47 | } 48 | @media (prefers-reduced-motion) { 49 | main, main * { 50 | transition-duration: 0s; 51 | } 52 | } 53 | :not(input) { 54 | user-select: none; 55 | } 56 | :focus { 57 | outline: 0; 58 | } 59 | 60 | /* -- */ 61 | 62 | main { 63 | --bg: #3C4042; 64 | --bg-x: #434649; 65 | --bg-xx: #505457; 66 | --txt: white; 67 | 68 | font-family: ${display_fonts}; 69 | font-size: 0.9rem; 70 | width: 25rem; 71 | max-width: 100vw; 72 | height: 100vh; 73 | position: fixed; 74 | bottom: 0; 75 | left: 0; 76 | padding: 0.5rem; 77 | display: flex; 78 | flex-direction: column-reverse; 79 | overflow: hidden; 80 | pointer-events: none; 81 | color: var(--txt); 82 | } 83 | #fields, 84 | #bar, 85 | #labels > * { 86 | border-radius: .5rem; 87 | box-shadow: 0 .1rem .25rem #0004; 88 | pointer-events: all; 89 | } 90 | :not(.edit) > #fields { 91 | display: none; 92 | } 93 | :not(.edit) > #bar{ 94 | border-radius: 1.5rem; 95 | flex-basis: 4rem; 96 | } 97 | #text:hover, #text:focus, 98 | #presets:hover, 99 | #bar > :hover, #bar > :focus, 100 | #tips > * { 101 | background: var(--bg-x); 102 | } 103 | #text:hover:focus, 104 | #presets:hover, 105 | #bar > :hover:focus { 106 | background: var(--bg-xx); 107 | } 108 | 109 | /* -- */ 110 | 111 | #tips { 112 | position: relative; 113 | font-family: ${body_fonts}; 114 | font-size: 0.8rem; 115 | line-height: 1rem; 116 | z-index: 10; 117 | } 118 | #tips > * { 119 | display: block; 120 | position: absolute; 121 | bottom: 0rem; 122 | height: 1.5rem; 123 | padding: 0.25rem; 124 | border-radius: 0.25rem; 125 | } 126 | #tips > :not(.show) { 127 | opacity: 0; 128 | } 129 | #tips > [for=minimize] { 130 | left: 0; 131 | } 132 | #tips > [for=previews] { 133 | left: 50%; 134 | transform: translateX(-50%); 135 | } 136 | #tips > [for=donate] { 137 | right: 0; 138 | } 139 | .edit > #tips > * { 140 | top: 1rem; 141 | } 142 | 143 | /* -- */ 144 | 145 | #bar { 146 | margin-top: 0.5rem; 147 | overflow: hidden; 148 | flex: 0 0 auto; 149 | display: flex; 150 | } 151 | .minimize > #bar { 152 | width: 1rem; 153 | } 154 | #bar > * { 155 | background: var(--bg); 156 | } 157 | #bar > :not(#previews) { 158 | font-size: 0.5rem; 159 | flex: 0 0 1.5rem; 160 | width: var(--radius); 161 | text-align: center; 162 | line-height: 4rem; 163 | height: 100%; 164 | overflow-wrap: anywhere; 165 | } 166 | .edit > #bar > :not(#previews), 167 | .edit > #bar > #previews > h2, 168 | .minimize #bar :not(#minimize) { 169 | display: none; 170 | } 171 | :not(.minimize) > #bar > #minimize:hover { 172 | padding-right: 2px; 173 | } 174 | .minimize > #bar:hover > #minimize, 175 | #donate:hover { 176 | padding-left: 2px; 177 | } 178 | .minimize > #bar > #minimize{ 179 | flex-basis: 1rem; 180 | } 181 | #previews { 182 | flex: 1 0 0; 183 | width: 0; 184 | display: flex; 185 | } 186 | #previews > :not(h2) { 187 | width: auto; 188 | height: auto; 189 | background-image: linear-gradient(90deg, 190 | hsl( 18, 100%, 68%) 16.7%, hsl(-10, 100%, 80%) 16.7%, 191 | hsl(-10, 100%, 80%) 33.3%, hsl(5,90%, 72%) 33.3%, 192 | hsl(5,90%, 72%) 50%, hsl( 48, 100%, 75%) 50%, 193 | hsl( 48, 100%, 75%) 66.7%, hsl( 36, 100%, 70%) 66.7%, 194 | hsl( 36, 100%, 70%) 83.3%, hsl( 20,90%, 70%) 83.3% 195 | ); 196 | } 197 | .edit > #bar > #previews > :not(h2) { 198 | height: auto; 199 | max-width: 50%; 200 | object-fit: contain; 201 | } 202 | #previews > h2 { 203 | flex-grow: 1; 204 | font-size: .9rem; 205 | line-height: 1.4; 206 | display: flex; 207 | text-align: center; 208 | align-items: center; 209 | justify-content: center; 210 | } 211 | #previews:hover > h2 { 212 | transform: translateY(-2px); 213 | } 214 | 215 | /* -- */ 216 | 217 | #fields { 218 | display: flex; 219 | flex-direction: column; 220 | overflow: hidden scroll; 221 | padding: 1rem; 222 | flex: 0 1 auto; 223 | background: var(--bg); 224 | } 225 | #presets, 226 | #fields > label { 227 | display: flex; 228 | align-items: center; 229 | justify-content: space-between; 230 | } 231 | #fields > label+label { 232 | margin-top: 0.5rem; 233 | } 234 | #fields > label:focus-within{ 235 | font-weight: bold; 236 | } 237 | #fields > label > * { 238 | width: calc(100% - 4.5rem); 239 | height: 1rem; 240 | border-radius: 0.5rem; 241 | border: 0.15rem solid var(--bg-x); 242 | font-size: 0.8rem; 243 | } 244 | #presets:focus-within, 245 | #fields > label > :focus, 246 | #fields > label > :hover { 247 | border-color: var(--txt); 248 | } 249 | #fields > label > #presets { 250 | overflow: hidden; 251 | height: 1.5rem; 252 | } 253 | #presets > * { 254 | flex-grow: 1; 255 | height: 100%; 256 | font-weight: normal; 257 | } 258 | #presets > :hover { 259 | background: var(--bg); 260 | } 261 | #presets > :focus { 262 | background: var(--txt); 263 | color: var(--bg); 264 | } 265 | #fields > label > #text { 266 | text-align: center; 267 | font-weight: bold; 268 | resize: none; 269 | line-height: 1.1; 270 | overflow: hidden scroll; 271 | background: var(--bg); 272 | height: auto; 273 | } 274 | #text::placeholder { 275 | color: inherit; 276 | } 277 | #text::selection { 278 | color: var(--bg); 279 | background: var(--txt); 280 | } 281 | input[type=checkbox] { 282 | cursor: pointer; 283 | } 284 | input[type=range] { 285 | -webkit-appearance: none; 286 | cursor: ew-resize; 287 | --gradient: transparent, transparent; 288 | --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%); 289 | background: linear-gradient(90deg, var(--gradient)), linear-gradient(90deg, var(--rainbow)); 290 | } 291 | input[type=range]::-webkit-slider-thumb { 292 | -webkit-appearance: none; 293 | transition: inherit; 294 | background: var(--bg); 295 | width: 1rem; 296 | height: 1rem; 297 | border: 0.1rem solid var(--txt); 298 | transform: scale(1.5); 299 | border-radius: 100%; 300 | } 301 | input[type=range]:hover::-webkit-slider-thumb { 302 | background: var(--bg-x); 303 | } 304 | input[type=range]:focus::-webkit-slider-thumb { 305 | border-color: var(--bg); 306 | background: var(--txt); 307 | } 308 | input[type=range]::-moz-range-thumb { 309 | transition: inherit; 310 | background: var(--bg); 311 | width: 1rem; 312 | height: 1rem; 313 | border: 0.1rem solid var(--txt); 314 | transform: scale(1.5); 315 | border-radius: 100%; 316 | box-sizing: border-box; 317 | } 318 | input[type=range]:hover::-moz-range-thumb { 319 | background: var(--bg-x); 320 | } 321 | input[type=range]:focus::-moz-range-thumb { 322 | border-color: var(--bg); 323 | background: var(--txt); 324 | } 325 | input#light, 326 | input#fade, 327 | input#vignette { 328 | --gradient: black, #8880, white 329 | } 330 | input#contrast { 331 | --gradient: gray, #8880 332 | } 333 | input#warmth, 334 | input#tilt { 335 | --gradient: #88f, #8880, #ff8 336 | } 337 | input#tint, 338 | input#pan { 339 | --gradient: #f8f, #8880, #8f8 340 | } 341 | input#sepia { 342 | --gradient: #8880, #aa8 343 | } 344 | input#hue, 345 | input#rotate { 346 | --gradient: var(--rainbow), var(--rainbow) 347 | } 348 | input#saturate { 349 | --gradient: gray, #8880 50%, blue, magenta 350 | } 351 | input#blur { 352 | --gradient: #8880, gray 353 | } 354 | input#scale, 355 | input#pillarbox, 356 | input#letterbox { 357 | --gradient: black, white 358 | } 359 | ` 360 | 361 | // Translate labels 362 | // Top languages of users: English, Portuguese, Spanish, Italian, Polish 363 | 364 | const i18n = { 365 | light: { en: 'light', es: 'brillo', fr: 'lumin', it: 'lumin', pt: 'brilho', zh: '亮度' }, 366 | contrast: { en: 'contrast', es: 'contraste', fr: 'contraste', it: 'contrasto', pt: 'contraste', zh: '对比度' }, 367 | warmth: { en: 'warmth', es: 'calor', fr: 'chaleur', it: 'calore', pt: 'calor', zh: '温度' }, 368 | tint: { en: 'tint', es: 'tinción', fr: 'teinte', it: 'tinta', pt: 'verde', zh: '色调' }, 369 | sepia: { en: 'sepia', es: 'sepia', fr: 'sépia', it: 'seppia', pt: 'sépia', zh: '泛黄' }, 370 | hue: { en: 'hue', es: 'tono', fr: 'ton', it: 'tonalità', pt: 'matiz', zh: '色相' }, 371 | saturate: { en: 'saturate', es: 'satura', fr: 'sature', it: 'saturare', pt: 'satura', zh: '饱和度' }, 372 | blur: { en: 'blur', es: 'difuminar', fr: 'flou', it: 'sfocatura', pt: 'enevoa', zh: '模糊' }, 373 | fade: { en: 'fade', es: 'fundido', fr: 'fondu', it: 'svanisci', pt: 'fundido', zh: '淡出' }, 374 | vignette: { en: 'vignette', es: 'viñeta', fr: 'vignette', it: 'vignetta', pt: 'vinheta', zh: '虚光照' }, 375 | rotate: { en: 'rotate', es: 'rota', fr: 'pivote', it: 'ruoti', pt: 'rota', zh: '旋转' }, 376 | scale: { en: 'scale', es: 'zoom', fr: 'zoom', it: 'scala', pt: 'zoom', zh: '大小' }, 377 | pan: { en: 'pan', es: 'panea', fr: 'pan', it: 'sposti-h', pt: 'panea', zh: '左右移动' }, 378 | tilt: { en: 'tilt', es: 'inclina', fr: 'incline', it: 'sposti-v', pt: 'empina', zh: '上下移动' }, 379 | pillarbox: { en: 'pillarbox', es: 'recorta-h', fr: 'taille-h', it: 'tagli-h', pt: 'recorta-h', zh: '左右裁剪' }, 380 | letterbox: { en: 'letterbox', es: 'recorta-v', fr: 'taille-v', it: 'tagli-v', pt: 'recorta-v', zh: '上下裁剪' }, 381 | text: { en: 'text', es: 'texto', fr: 'texte', it: 'testo', pt: 'texto', zh: '文字' }, 382 | mirror: { en: 'mirror', es: 'refleja', fr: 'réfléch', it: 'rispecchi', pt: 'refleja', zh: '反射' }, 383 | freeze: { en: 'freeze', es: 'pausa', fr: 'arrête', it: 'pausa', pt: 'pausa', zh: '暂停' }, 384 | presets: { en: 'presets', es: 'estilos', fr: 'styles', it: 'stili', pt: 'estilos', zh: '预设' }, 385 | preset: { en: 'preset: ', es: 'estilo: ', fr: 'style: ', it: 'stile: ', pt: 'estilo: ', zh: '预设:' }, 386 | reset: { en: 'reset', es: 'reini', fr: 'réinit', it: 'reset', pt: 'reini', zh: '重置' }, 387 | open_tip: { en: 'Open', es: 'Abre', fr: 'Ouvre', it: 'Apri', pt: 'Aberto', zh: '打开' }, 388 | close_tip: { en: 'Close', es: 'Cierra', fr: 'Ferme', it: 'Chiudi', pt: 'Feche', zh: '合起' }, 389 | minimize_tip: { en: 'Minimize', es: 'Minimizas', fr: 'Minimise', it: 'Minimizzi', pt: 'Minimiza', zh: '合起' }, 390 | previews_tip: { en: ' previews', es: ' visualizaciones', fr: ' aperçus', it: ' anteprima', pt: 'visualizações', zh: '预览' }, 391 | studio_tip: { en: ' studio', es: ' estudio', fr: ' studio', it: ' studio', pt: ' estúdio', zh: '画室' }, 392 | 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: '在这里写字' }, 393 | 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: '捐款给作者' }, 394 | } 395 | const langs = [ 'en', 'es', 'fr', 'it', 'pt', 'zh' ] 396 | main.lang = langs.find( x => x === navigator.language.split('-')[0] ) || 'en' 397 | for(const key in i18n) i18n[key] = i18n[key][main.lang] 398 | 399 | // Create inputs 400 | 401 | const fields = document.createElement('section') 402 | fields.id= 'fields' 403 | 404 | const types = { 405 | light: 'range', 406 | contrast: 'range', 407 | warmth: 'range', 408 | tint: 'range', 409 | sepia: 'range_positive', 410 | hue: 'range_loop', 411 | saturate: 'range', 412 | blur: 'range_positive', 413 | fade: 'range', 414 | vignette: 'range', 415 | rotate: 'range_loop', 416 | scale: 'range_positive', 417 | pan: 'range', 418 | tilt: 'range', 419 | pillarbox: 'range_positive', 420 | letterbox: 'range_positive', 421 | text: 'textarea', 422 | mirror: 'checkbox', 423 | freeze: 'checkbox', 424 | presets: 'radio', 425 | } 426 | const default_values = { 427 | light: 0, 428 | contrast: 0, 429 | warmth: 0, 430 | tint: 0, 431 | sepia: 0, 432 | hue: 0, 433 | saturate: 0, 434 | blur: 0, 435 | fade: 0, 436 | vignette: 0, 437 | rotate: 0, 438 | scale: 0, 439 | pan: 0, 440 | tilt: 0, 441 | pillarbox: 0, 442 | letterbox: 0, 443 | text: '', 444 | mirror: false, 445 | freeze: false, 446 | presets: 'reset', 447 | } 448 | const saved_values = Object.fromEntries(Object.entries( 449 | JSON.parse(window.localStorage.getItem('mercator-studio-values-20')) || {} 450 | ).filter(([key]) => key in default_values)) 451 | 452 | const preset_values = { 453 | reset: {}, 454 | concorde: { 455 | contrast: 0.1, 456 | warmth: -0.25, 457 | tint: -0.05, 458 | saturate: 0.2, 459 | }, 460 | mono: { 461 | light: 0.1, 462 | contrast: -0.1, 463 | sepia: 0.8, 464 | saturate: -1, 465 | vignette: -0.5, 466 | }, 467 | matcha: { 468 | light: 0.1, 469 | tint: -0.75, 470 | sepia: 1, 471 | hue: 0.2, 472 | vignette: 0.3, 473 | fade: 0.3, 474 | }, 475 | deepfry: { 476 | contrast: 1, 477 | saturate: 0.5, 478 | } 479 | } 480 | 481 | // Clone default values into updating object 482 | const values = { 483 | ...default_values, 484 | ...saved_values, 485 | freeze: false, 486 | } 487 | 488 | const inputs = Object.fromEntries( 489 | Object.entries(values) 490 | .map(([key, value]) => { 491 | let input 492 | const type = types[key] 493 | switch (type) { 494 | case 'textarea': 495 | input = document.createElement('textarea') 496 | input.rows = 3 497 | input.placeholder = `\n🌈 ${i18n.text_tip} 🌦️` 498 | input.addEventListener('input', () => 499 | // String substitution 500 | set_value(input, input.value 501 | .replace(/--/g, '―') 502 | .replace(/\\sqrt/g, '√') 503 | .replace(/\\pm/g, '±') 504 | .replace(/\\times/g, '×') 505 | .replace(/\\cdot/g, '·') 506 | .replace(/\\over/g, '∕') 507 | // Numbers starting with ^ (superscript) or _ (subscript) 508 | .replace(/(\^|\_)(\d+)/g, (_, sign, number) => 509 | number.split('') 510 | .map( char => '₀₁₂₃₄₅₆₇₈₉⁰¹²³⁴⁵⁶⁷⁸⁹'[ (sign === '^')*10 + parseInt(char) ] ) 511 | .join('') 512 | ) 513 | ) 514 | ) 515 | break 516 | case 'checkbox': 517 | input = document.createElement('input') 518 | input.type = 'checkbox' 519 | input.addEventListener('change', () => 520 | set_value(input, input.checked) 521 | ) 522 | break 523 | case 'radio': 524 | input = document.createElement('label') 525 | input.append(...Object.keys(preset_values).map(key => { 526 | const button = document.createElement('button') 527 | const reset = key === 'reset' 528 | button.textContent = reset ? i18n.reset : key 529 | button.setAttribute('aria-label', i18n.preset + button.textContent) 530 | button.addEventListener('click', event => { 531 | event.preventDefault() 532 | Object.entries({...default_values,...preset_values[key]}) 533 | .filter(([key]) => !['radio','checkbox'].includes(types[key])) 534 | .forEach(([key, value]) => set_value(inputs[key], value)) 535 | }) 536 | return button 537 | })) 538 | break 539 | default: 540 | input = document.createElement('input') 541 | input.type = 'range' 542 | 543 | // These inputs go from 0 to 1, the rest -1 to 1 544 | input.min = ( type === 'range_positive' ) - 1 545 | input.max = 1 546 | 547 | // Use 32 steps normally, 128 if CTRL, 512 if SHIFT 548 | const range = input.max - input.min 549 | input.step = range / 32 550 | input.addEventListener('keydown', ({ code, ctrlKey, shiftKey }) => { 551 | if(code === 'Digit0') reset_value(input) 552 | input.step = range / (shiftKey ? 512 : ctrlKey ? 128 : 32) 553 | }) 554 | input.addEventListener('keyup', () => 555 | input.step = range / 32 556 | ) 557 | 558 | input.addEventListener('input', () => { 559 | input.focus() 560 | set_value(input, input.valueAsNumber) 561 | }) 562 | 563 | // Scroll to change values 564 | input.addEventListener('wheel', event => { 565 | event.preventDefault() 566 | input.focus() 567 | const width = input.getBoundingClientRect().width 568 | const dx = -event.deltaX 569 | const dy = event.deltaY 570 | const ratio = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / width 571 | const range = input.max - input.min 572 | const raw_value = input.valueAsNumber + ratio * range 573 | const clamped_value = Math.min(Math.max(raw_value, input.min), input.max) 574 | const stepped_value = Math.round(clamped_value / input.step) * input.step 575 | const value = stepped_value 576 | set_value(input, value) 577 | }) 578 | 579 | // Right click to individually reset 580 | input.addEventListener('contextmenu', event => { 581 | event.preventDefault() 582 | reset_value(input) 583 | }) 584 | } 585 | 586 | set_value(input,value) 587 | input.id = key 588 | 589 | if (!(isFirefox && ['warmth', 'tint'].includes(key))) { 590 | // Disable the SVG filters for Firefox 591 | let label = document.createElement('label') 592 | label.textContent = i18n[key] 593 | 594 | label.append(input) 595 | fields.append(label) 596 | } 597 | return [key, input] 598 | }) 599 | ) 600 | 601 | function set_value(input, value) { 602 | values[input.id] = input.value = input.checked = value 603 | window.localStorage.setItem('mercator-studio-values-20', JSON.stringify(values)) 604 | } 605 | function reset_value(input) { 606 | set_value(input, default_values[input.id]) 607 | } 608 | 609 | // Create color balance matrix 610 | const svgNS = 'http://www.w3.org/2000/svg' 611 | const svg = document.createElementNS(svgNS, 'svg') 612 | const filter = document.createElementNS(svgNS, 'filter') 613 | filter.id = 'filter' 614 | const component_transfer = document.createElementNS(svgNS, 'feComponentTransfer') 615 | const components = Object.fromEntries( 616 | ['R', 'G', 'B'].map(hue => { 617 | const func = document.createElementNS(svgNS, 'feFunc' + hue) 618 | func.setAttribute('type', 'table') 619 | func.setAttribute('tableValues', '0 1') 620 | return [hue, func] 621 | })) 622 | component_transfer.append(...Object.values(components)) 623 | filter.append(component_transfer) 624 | svg.append(filter) 625 | 626 | // Create labels 627 | 628 | const minimize_tip = document.createElement('label') 629 | minimize_tip.htmlFor = 'minimize' 630 | minimize_tip.dataset.off = `${i18n.minimize_tip}${i18n.previews_tip} (ctrl + shift + m)` 631 | minimize_tip.dataset.on = `${i18n.open_tip}${i18n.previews_tip} (ctrl + shift + m)` 632 | minimize_tip.textContent = minimize_tip.dataset.off 633 | 634 | const previews_tip = document.createElement('label') 635 | previews_tip.htmlFor = 'previews' 636 | previews_tip.dataset.off = `${i18n.open_tip}${i18n.studio_tip} (ctrl + m)` 637 | previews_tip.dataset.on = `${i18n.close_tip}${i18n.studio_tip} (ctrl + m)` 638 | previews_tip.textContent = previews_tip.dataset.off 639 | 640 | const donate_tip = document.createElement('label') 641 | donate_tip.htmlFor = 'donate' 642 | donate_tip.textContent = i18n.donate_tip 643 | 644 | const tips = document.createElement('section') 645 | tips.id = 'tips' 646 | tips.append(minimize_tip,previews_tip,donate_tip) 647 | 648 | // Mimic Google Meet tooltip behavior where hover gets priority over focused 649 | const update_tips = () => { 650 | tips.querySelectorAll('.show').forEach(tip=>tip.classList.remove('show')) 651 | const show = tips.querySelector('.hover') || tips.querySelector('.focus') 652 | if(show) show.classList.add('show') 653 | } 654 | const link_tip = ( original, tip ) => { 655 | original.addEventListener('mouseenter',()=>{ 656 | tip.classList.add('hover') 657 | update_tips() 658 | }) 659 | original.addEventListener('mouseleave',()=>{ 660 | tip.classList.remove('hover') 661 | update_tips() 662 | }) 663 | original.addEventListener('focus',()=>{ 664 | tip.classList.add('focus') 665 | update_tips() 666 | }) 667 | original.addEventListener('blur',()=>{ 668 | tip.classList.remove('focus') 669 | update_tips() 670 | }) 671 | } 672 | 673 | // create bottom bar 674 | 675 | const bar = document.createElement('section') 676 | bar.id = 'bar' 677 | 678 | const minimize = document.createElement('button') 679 | minimize.id = 'minimize' 680 | minimize.textContent = '◀' 681 | const toggleMinimize = () => { 682 | main.classList.remove('edit') 683 | main.classList.toggle('minimize') 684 | minimize.focus() 685 | const state = main.classList.contains('minimize') 686 | minimize.textContent = state ? '▶' : '◀' 687 | minimize_tip.textContent = minimize_tip.dataset[ state ? 'on' : 'off' ] 688 | minimize_tip.classList.remove('focus') 689 | update_tips() 690 | } 691 | minimize.addEventListener('click', toggleMinimize) 692 | link_tip(minimize,minimize_tip) 693 | 694 | const donate = document.createElement('a') 695 | donate.id = 'donate' 696 | donate.href = 'https://ko-fi.com/xingyzt' 697 | donate.target = '_blank' 698 | donate.textContent = '🤍' 699 | donate.setAttribute('aria-label',i18n.donate_tip) 700 | link_tip(donate,donate_tip) 701 | 702 | 703 | // Create previews 704 | const previews = document.createElement('button') 705 | previews.id = 'previews' 706 | const toggleEdit = () => { 707 | main.classList.remove('minimize') 708 | main.classList.toggle('edit') 709 | previews.focus() 710 | const state = main.classList.contains('edit') 711 | state ? Object.values(inputs)[0].focus() : previews.focus() 712 | previews_tip.textContent = previews_tip.dataset[state ? 'on' : 'off'] 713 | previews_tip.classList.remove('focus') 714 | update_tips() 715 | } 716 | previews.addEventListener('click', toggleEdit) 717 | link_tip(previews,previews_tip) 718 | 719 | // Ctrl+m to toggle 720 | window.addEventListener('keydown', event => { 721 | if (event.code=='KeyM' && event.ctrlKey) { 722 | event.preventDefault() 723 | event.shiftKey ? toggleMinimize(event) : toggleEdit(event) 724 | } 725 | }) 726 | 727 | // Create preview video 728 | const video = document.createElement('video') 729 | video.setAttribute('playsinline', '') 730 | video.setAttribute('autoplay', '') 731 | video.setAttribute('muted', '') 732 | 733 | // Create canvases 734 | const canvases = Object.fromEntries(['buffer', 'freeze', 'display'].map(name => { 735 | const element = document.createElement('canvas') 736 | const context = element.getContext('2d') 737 | return [name, { 738 | element, 739 | context 740 | }] 741 | })) 742 | 743 | // Create title 744 | const title = document.createElement('h2') 745 | title.id = 'title' 746 | title.innerText = 'Mercator\nStudio' 747 | 748 | previews.append(video, title, canvases.buffer.element) 749 | bar.append(minimize, previews, donate) 750 | 751 | // Add UI to page 752 | main.append(bar, tips, fields) 753 | shadow.append(main, style, svg) 754 | document.body.append(host) 755 | 756 | // Define mappings of linear values 757 | const polynomial_map = (value, degree) => (value + 1) ** degree 758 | const polynomial_table = (factor, steps = 32) => Array(steps).fill(0) 759 | .map((_, index) => Math.pow(index / (steps - 1), 2 ** factor)).join(' ') 760 | const percentage = (value) => value * 100 + '%' 761 | 762 | const amp = 8 763 | 764 | let task = 0 765 | 766 | const freeze = { 767 | state: false, 768 | init: false, 769 | image: document.createElement('img'), 770 | canvas: canvases.freeze, 771 | } 772 | inputs.freeze.addEventListener('change', () => { 773 | freeze.state = freeze.init = inputs.freeze.checked 774 | }) 775 | // Background Blur for Google Meet does this (hello@brownfoxlabs.com) 776 | 777 | function draw( width, height ) { 778 | 779 | const center = [width / 2, height / 2] 780 | const fill = [0, 0, width, height] 781 | const { context } = canvases.buffer 782 | 783 | context.clearRect(...fill) 784 | 785 | // Get values 786 | 787 | inputs.hue.value %= 1 788 | inputs.rotate.value %= 1 789 | 790 | let v = values 791 | 792 | let light = percentage(polynomial_map(v.light, 2)) 793 | let contrast = percentage(polynomial_map(v.contrast, 3)) 794 | let warmth = isFirefox ? 0 : v.warmth 795 | let tint = isFirefox ? 0 : v.tint 796 | let sepia = percentage(v.sepia) 797 | let hue = 360 * v.hue + 'deg' 798 | let saturate = percentage(amp ** v.saturate) 799 | let blur = v.blur * width / 16 + 'px' 800 | let fade = v.fade 801 | let vignette = v.vignette 802 | let rotate = v.rotate * 2 * Math.PI 803 | let scale = polynomial_map(v.scale, 2) 804 | let mirror = v.mirror 805 | let move_x = v.pan * width 806 | let move_y = v.tilt * height 807 | let pillarbox = v.pillarbox * width / 2 808 | let letterbox = v.letterbox * height / 2 809 | let text = v.text.split('\n') 810 | 811 | // Color balance 812 | 813 | components.R.setAttribute('tableValues', polynomial_table(-warmth + tint / 2)) 814 | components.G.setAttribute('tableValues', polynomial_table(-tint)) 815 | components.B.setAttribute('tableValues', polynomial_table( warmth + tint / 2)) 816 | 817 | // CSS filters 818 | 819 | context.filter = (` 820 | brightness(${light}) 821 | contrast(${contrast}) 822 | ${'url(#filter)'.repeat(Boolean(warmth||tint))} 823 | sepia(${sepia}) 824 | hue-rotate(${hue}) 825 | saturate(${saturate}) 826 | blur(${blur}) 827 | `) 828 | 829 | // Linear transformations: rotation, scaling, translation 830 | context.translate(...center) 831 | if (rotate) context.rotate(rotate) 832 | if (scale - 1) context.scale(scale, scale) 833 | if (mirror) context.scale(-1, 1) 834 | if (move_x || move_y) context.translate(move_x, -move_y) 835 | context.translate(...center.map(x=>-x)) 836 | 837 | // Apply CSS filters & linear transformations 838 | if (freeze.init) { 839 | freeze.canvas.context.drawImage(video, ...fill) 840 | let data = freeze.canvas.element.toDataURL('image/png') 841 | freeze.image.setAttribute('src', data) 842 | freeze.init = false 843 | } else if (freeze.state) { 844 | // Draw frozen image 845 | context.drawImage(freeze.image, ...fill) 846 | } else if (video.srcObject) { 847 | // Draw video 848 | context.drawImage(video, ...fill) 849 | } else { 850 | // Draw preview stripes if video doesn't exist 851 | '18, 100%, 68%; -10,100%,80%; 5, 90%, 72%; 48, 100%, 75%; 36, 100%, 70%; 20, 90%, 70%' 852 | .split(';') 853 | .forEach((color, index) => { 854 | context.fillStyle = `hsl(${color})` 855 | context.fillRect(index * width / 6, 0, width / 6, height) 856 | }) 857 | } 858 | 859 | // Clear transforms & filters 860 | context.setTransform(1, 0, 0, 1, 0, 0) 861 | context.filter = 'brightness(1)' 862 | 863 | // Fade: cover the entire image with a single color 864 | if (fade) { 865 | let fade_lum = Math.sign(fade) * 100 866 | let fade_alpha = Math.abs(fade) 867 | 868 | context.fillStyle = `hsla(0,0%,${fade_lum}%,${fade_alpha})` 869 | context.fillRect(...fill) 870 | } 871 | 872 | // Vignette: cover the edges of the image with a single color 873 | if (vignette) { 874 | let vignette_lum = Math.sign(vignette) * 100 875 | let vignette_alpha = Math.abs(vignette) 876 | let vignette_gradient = context.createRadialGradient( 877 | ...center, 0, 878 | ...center, center.reduce( (x,y) => Math.sqrt(x**2 + y**2) ) 879 | ) 880 | 881 | vignette_gradient.addColorStop(0, `hsla(0,0%,${vignette_lum}%,0`) 882 | vignette_gradient.addColorStop(1, `hsla(0,0%,${vignette_lum}%,${vignette_alpha}`) 883 | 884 | context.fillStyle = vignette_gradient 885 | context.fillRect(...fill) 886 | 887 | } 888 | 889 | // Pillarbox: crop width 890 | if (pillarbox) { 891 | context.clearRect(0, 0, pillarbox, height) 892 | context.clearRect(width, 0, -pillarbox, height) 893 | } 894 | 895 | // Letterbox: crop height 896 | if (letterbox) { 897 | context.clearRect(0, 0, width, letterbox) 898 | context.clearRect(0, height, width, -letterbox) 899 | } 900 | 901 | // Text: 902 | if (text) { 903 | 904 | // Find out the font size that just fits 905 | 906 | const vw = 0.9 * (width - 2 * pillarbox) 907 | const vh = 0.9 * (height - 2 * letterbox) 908 | const line_count = text.length 909 | 910 | context.font = `bold ${vw}px ${display_fonts}` 911 | context.textAlign = 'center' 912 | context.textBaseline = 'middle' 913 | 914 | let char_metrics = context.measureText('0') 915 | let line_height = char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent 916 | let text_width = text.reduce( 917 | (max_width, current_line) => Math.max( 918 | max_width, 919 | context.measureText(current_line).width 920 | ), 0 // Accumulator starts at 0 921 | ) 922 | 923 | const font_size = Math.min(vw ** 2 / text_width, vh ** 2 / line_height / line_count) 924 | 925 | // Found the font size. Time to draw! 926 | 927 | context.font = `bold ${font_size}px ${display_fonts}` 928 | 929 | char_metrics = context.measureText('0') 930 | line_height = 1.5 * (char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent) 931 | 932 | context.lineWidth = font_size / 8 933 | context.strokeStyle = 'black' 934 | context.fillStyle = 'white' 935 | 936 | text.forEach((line, index) => { 937 | let position = [ ...center ] 938 | position[1] += line_height * (index - line_count / 2 + 0.5) 939 | context.strokeText(line, ...position) 940 | context.fillText(line, ...position) 941 | }) 942 | } 943 | 944 | canvases.display.context.clearRect(...fill) 945 | canvases.display.context.drawImage(canvases.buffer.element, 0, 0) 946 | } 947 | 948 | class mercator_studio_MediaStream extends MediaStream { 949 | 950 | constructor(old_stream) { 951 | 952 | // Copy original stream settings 953 | super(old_stream) 954 | video.srcObject = old_stream 955 | 956 | const { width, height } = old_stream.getVideoTracks()[0].getSettings() 957 | Object.values(canvases).forEach(canvas => { 958 | canvas.element.width = width 959 | canvas.element.height = height 960 | }) 961 | // Amp: for values that can range from 0 to +infinity, amp**value does the mapping. 962 | clearInterval(task) 963 | const fps = 30 964 | task = setInterval(draw, 1000/fps, width, height) 965 | const new_stream = canvases.display.element.captureStream(fps) 966 | new_stream.addEventListener('inactive', () => { 967 | old_stream.getTracks().forEach(track => track.stop()) 968 | canvases.display.context.clearRect(...fill) 969 | video.srcObject = null 970 | }) 971 | return new_stream 972 | } 973 | } 974 | 975 | MediaDevices.prototype.old_getUserMedia = MediaDevices.prototype.getUserMedia 976 | MediaDevices.prototype.getUserMedia = async constraints => 977 | (constraints && constraints.video && !constraints.audio) ? 978 | new mercator_studio_MediaStream(await navigator.mediaDevices.old_getUserMedia(constraints)) : 979 | navigator.mediaDevices.old_getUserMedia(constraints) 980 | })() 981 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mercator Studio 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 98 | 99 | 100 |
101 | 102 | Logo of Mercator Studio for Google Meet 103 | A white video recorder icon in a dark teal circle, wrapped by stripes in shades of pastel orange. 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | (C) Xing Liu, 2020 119 | 120 |

Mercator Studio for Google Meet

121 |

A project by Xing

122 |
123 | 124 | 125 | Chrome Web Store 126 | 127 | 128 | 129 | Firefox Addons 130 | 131 | 132 | 133 | Greasyfork Scripts 134 | 135 | 136 | 137 | 138 | 139 |
140 | 141 | 142 |
143 | Mercator Studio gives you fine control over your appearance on Google Meet. 144 | 145 | Precisely adjust lighting and colors: 146 | · Exposure & Contrast 147 | · Temperature & Tint 148 | · Hue & Saturation 149 | · Sepia & Blur 150 | · Fade & Vignette 151 | 152 | Move the focus to where you want it: 153 | · Rotate, Scale, Mirror & Flip 154 | · Horizontal & Vertical Translate 155 | · Pillarbox & Letterbox Crop 156 | 157 | Write text & emoji in front of your face: 158 | · Auto-adjusts size to fit any length of text onto the screen. 159 | · Auto-converts \sqrt to √, \times to ×, \cdot to ·, \pm to ±, ^number to ¹², and _number to ₄₂. 160 | 161 | Somewhat nice presets: 162 | · Concorde 163 | · Mono 164 | · Matcha 165 | · Deepfry 166 | 167 | Scroll, drag, or use arrow keys on the sliders to adjust; Right click or press 0 on them to reset; And hold down Ctrl or Shift for finer steps. 168 | 169 | Ctrl + M to open/close the interface. Ctrl + Shift + M to minimize it. 170 | 171 | Translated for português, español, italiano, français, & 中文. 172 | 173 | Changelog: 174 | · 2.2 Translate from EN into PT, ES, IT, FR, and ZH. 175 | · 2.1 Improve keyboard navigation. 176 | · 2.0 Redesign for Google Meet’s new look. 177 | · 1.19 Add mirroring; Dark mode support. 178 | · 1.18 Make textbox auto-resize; Ctrl or Shift for finer steps. 179 | · 1.17 Fix flickering and window-focus issues. 180 | · 1.16 Add freeze feature (thanks @napsav). 181 | · 1.15 Add toggle to super tiny mode. 182 | · 1.14 Add math auto-convert. 183 | · 1.13 Preserve values across sessions. 184 | · 1.12 Luminance-preserving temperature & tint. 185 | · 1.11 Multiline text input; Rebranded as Mercator Studio for Google Meet. 186 | · 1.10 Sync camera; Capture scroll; Right-click reset; Firefox support. 187 | · 1.9 Add text & emoji input. 188 | · 1.8 Add presets; Matched UI with material design. 189 | · 1.7 Add color balance tools and refined UI. 190 | · 1.6 Add fog. 191 | · 1.5 Add vignettes. 192 | · 1.4 Converted to Chrome extension. 193 | · 1.3 Fix the blur slider's range. 194 | · 1.2 Add cropping. 195 | · 1.1 Add a way to reset everything. 196 | · 1.0 Hello world! 197 | 198 | Source code: https://github.com/FlyOrBoom/mercator. 199 | 200 | Available for other browsers: https://x-ing.space/mercator. 201 | Unfortunately, temperature & tint filters don't work in Firefox. 202 | 203 | (C) Xing Liu 2020–2021, MIT License. 204 | 205 | Check out other things I’ve made at https://x-ing.space. 206 |
207 |
208 | 209 | 224 | 225 | 226 | --------------------------------------------------------------------------------