├── 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 |
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 |
120 | Mercator Studio for Google Meet
121 | A project by Xing
122 |
140 |
141 |
142 |
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 |
120 | Mercator Studio for Google Meet
121 | A project by Xing
122 |
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 |
--------------------------------------------------------------------------------