Professional Documents
Culture Documents
// Create form
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
:not(input) {
user-select: none;
}
@media (prefers-reduced-motion) {
* {
transition-duration: 0s;
}
}
:focus {
outline: 0;
}
/* -- */
main {
--bg: #3C4042;
--bg-x: #434649;
--bg-xx: #505457;
--txt: white;
font-family: ${display_fonts};
font-size: 0.9rem;
width: 25rem;
max-width: 100vw;
height: 100vh;
position: fixed;
bottom: 0;
left: 0;
padding: 0.5rem;
display: flex;
flex-direction: column-reverse;
overflow: hidden;
pointer-events: none;
}
main > * {
color: var(--txt);
}
#fields,
#bar,
#labels > * {
border-radius: .5rem;
box-shadow: 0 .1rem .25rem #0004;
pointer-events: all;
}
:not(.edit)>#fields{
display: none;
opacity: 0;
}
:not(.edit)>#bar{
border-radius: 1.5rem;
flex-basis: 4rem;
}
#text:hover, #text:focus,
#presets:hover,
#bar>:hover, #bar>:focus,
#tips > * {
background: var(--bg-x);
}
#text:hover:focus,
#presets:hover,
#bar>:hover:focus {
background: var(--bg-xx);
}
/* -- */
#tips {
position: relative;
font-family: ${body_fonts};
font-size: 0.8rem;
line-height: 1rem;
z-index: 10;
}
#tips > * {
display: block;
position: absolute;
bottom: 0rem;
height: 1.5rem;
padding: 0.25rem;
border-radius: 0.25rem;
}
#tips > :not(.show) {
opacity: 0;
}
#tips > [for="minimize"] {
left: 0;
}
#tips > [for="previews"] {
left: 50%;
transform: translateX(-50%);
}
#tips > [for="donate"] {
right: 0;
}
.edit > #tips > * {
top: 1rem;
}
/* -- */
#bar {
margin-top: .5rem;
overflow: hidden;
flex: 0 0 auto;
display: flex;
}
.minimize #bar {
width: 1rem;
}
#bar > * {
background: var(--bg);
}
#bar #minimize,
#bar #donate {
font-size: .5rem;
flex: 0 0 1.5rem;
width: var(--radius);
text-align: center;
line-height: 4rem;
height: 100%;
overflow-wrap: anywhere;
}
.edit #bar #minimize,
.edit #bar #donate,
.edit #bar h2,
.minimize #bar :not(#minimize) {
display: none;
}
:not(.minimize) #minimize:hover,
.minimize #minimize:not(:hover) {
padding-right: 2px;
}
#donate:hover {
padding-left: 2px;
}
.minimize #minimize{
flex-basis: 1rem;
}
#previews {
flex: 1 0 0;
width: 0;
display: flex;
}
#previews video,
#previews canvas {
width: auto;
height: auto;
background-image: linear-gradient(90deg,
hsl( 18, 100%, 68%) 16.7%, hsl(-10, 100%, 80%) 16.7%,
hsl(-10, 100%, 80%) 33.3%, hsl(5,90%, 72%) 33.3%,
hsl(5,90%, 72%) 50%, hsl( 48, 100%, 75%) 50%,
hsl( 48, 100%, 75%) 66.7%, hsl( 36, 100%, 70%) 66.7%,
hsl( 36, 100%, 70%) 83.3%, hsl( 20,90%, 70%) 83.3%
);
}
.edit #previews video,
.edit #previews canvas {
height: auto;
max-width: 50%;
object-fit: contain;
}
#previews>h2 {
flex-grow: 1;
font-size: .9rem;
line-height: 1.4;
display: flex;
text-align: center;
align-items: center;
justify-content: center;
}
#previews:hover>h2 {
transform: translateY(-2px);
}
/* -- */
#fields {
display: flex;
flex-direction: column;
overflow: hidden scroll;
padding: 1rem;
flex: 0 1 auto;
background: var(--bg);
}
#presets,
#fields > label {
display: flex;
justify-content: space-between;
align-items: center;
}
#fields > label+label {
margin-top: 0.5rem;
}
#fields > label:focus-within{
font-weight: bold;
}
#fields > label > * {
width: calc(100% - 4.5rem);
height: 1rem;
border-radius: 0.5rem;
border: 0.15rem solid var(--bg-x);
font-size: 0.8rem;
}
#presets:focus-within,
#fields > label > :focus,
#fields > label > :hover {
border-width: 0.15rem;
border-color: var(--txt);
}
#fields > label > #presets {
overflow: hidden;
height: auto;
margin-bottom: -0.15rem;
}
#presets>* {
border: 0;
border-radius: 0;
background: transparent;
flex-grow: 1;
height: 1.3rem;
font-weight: normal;
}
#presets>:first-child {
border-radius: 0.25rem 0 0 0.25rem;
}
#presets>:last-child {
border-radius: 0 0.25rem 0.25rem 0;
}
#presets>:hover {
background: var(--bg);
}
#presets>:focus {
background: var(--txt);
color: var(--bg);
}
#fields > label > #text {
text-align: center;
font-weight: bold;
resize: none;
line-height: 1.1;
overflow: hidden scroll;
background: var(--bg);
height: auto;
}
#text::placeholder {
color: inherit;
}
#text::selection {
color: var(--bg);
background: var(--txt);
}
input[type=checkbox] {
cursor: pointer;
}
input[type=range] {
-webkit-appearance: none;
cursor: ew-resize;
--gradient: transparent, transparent;
--rainbow: hsl(0, 80%, 75%), hsl(30, 80%, 75%), hsl(60, 80%, 75%), hsl(90,
80%, 75%), hsl(120, 80%, 75%), hsl(150, 80%, 75%), hsl(180, 80%, 75%), hsl(210,
80%, 75%), hsl(240, 80%, 75%), hsl(270, 80%, 75%), hsl(300, 80%, 75%), hsl(330,
80%, 75%);
background: linear-gradient(90deg, var(--gradient)), linear-gradient(90deg,
var(--rainbow));
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
transition: inherit;
background: var(--bg);
width: 1rem;
height: 1rem;
border: 0.1rem solid var(--txt);
transform: scale(1.5);
border-radius: 100%;
}
input[type=range]:hover::-webkit-slider-thumb {
background: var(--bg-x);
}
input[type=range]:focus::-webkit-slider-thumb {
border-color: var(--bg);
background: var(--txt);
}
input[type=range]::-moz-range-thumb {
transition: inherit;
background: var(--bg);
width: 1rem;
height: 1rem;
border: 0.1rem solid var(--txt);
transform: scale(1.5);
border-radius: 100%;
box-sizing: border-box;
}
input[type=range]:hover::-moz-range-thumb {
background: var(--bg-x);
}
input[type=range]:focus::-moz-range-thumb {
border-color: var(--bg);
background: var(--txt);
}
input#light,
input#fade,
input#vignette {
--gradient: black, #8880, white
}
input#contrast {
--gradient: gray, #8880
}
input#warmth,
input#tilt {
--gradient: #88f, #8880, #ff8
}
input#tint,
input#pan {
--gradient: #f8f, #8880, #8f8
}
input#sepia {
--gradient: #8880, #aa8
}
input#hue,
input#rotate {
background: linear-gradient(90deg, hsl(0, 80%, 75%), hsl(60, 80%, 75%),
hsl(120, 80%, 75%), hsl(180, 80%, 75%), hsl(240, 80%, 75%), hsl(300, 80%, 75%),
hsl(0, 80%, 75%), hsl(60, 80%, 75%), hsl(120, 80%, 75%), hsl(180, 80%, 75%),
hsl(240, 80%, 75%), hsl(300, 80%, 75%), hsl(0, 80%, 75%))
}
input#saturate {
--gradient: gray, #8880 50%, blue, magenta
}
input#blur {
--gradient: #8880, gray
}
input#scale,
input#pillarbox,
input#letterbox {
--gradient: black, white
}
`
// Translate labels
// Top languages of users: English, Portuguese, Spanish, Italian, Polish
const i18n = {
light: { en: 'light', es: 'brillo', fr: 'lumin', it:
'lumin', pt: 'brilho', zh: '亮度' },
contrast: { en: 'contrast', es: 'contraste', fr: 'contraste', it:
'contrasto', pt: 'contraste', zh: '对比度' },
warmth: { en: 'warmth', es: 'calor', fr: 'chaleur', it:
'calore', pt: 'calor', zh: '温度' },
tint: { en: 'tint', es: 'tinción', fr: 'teinte', it: 'tinta',
pt: 'verde', zh: '色调' },
sepia: { en: 'sepia', es: 'sepia', fr: 'sépia', it:
'seppia', pt: 'sépia', zh: '泛黄' },
hue: { en: 'hue', es: 'tono', fr: 'ton', it: 'tonalità', pt:
'matiz', zh: '色相' },
saturate: { en: 'saturate', es: 'satura', fr: 'sature', it:
'saturare', pt: 'satura', zh: '饱和度' },
blur: { en: 'blur', es: 'difuminar', fr: 'flou', it: 'sfocatura',
pt: 'enevoa', zh: '模糊' },
fade: { en: 'fade', es: 'fundido', fr: 'fondu', it:
'svanisci', pt: 'fundido', zh: '淡出' },
vignette: { en: 'vignette', es: 'viñeta', fr: 'vignette', it:
'vignetta', pt: 'vinheta', zh: '虚光照' },
rotate: { en: 'rotate', es: 'rota', fr: 'pivote', it: 'ruoti',
pt: 'rota', zh: '旋转' },
scale: { en: 'scale', es: 'zoom', fr: 'zoom', it: 'scala',
pt: 'zoom', zh: '大小' },
pan: { en: 'pan', es: 'panea', fr: 'pan', it: 'sposti-h',
pt: 'panea', zh: '左右移动' },
tilt: { en: 'tilt', es: 'inclina', fr: 'incline', it: 'sposti-
v', pt: 'empina', zh: '上下移动' },
pillarbox: { en: 'pillarbox', es: 'recorta-h', fr: 'taille-h',
it: 'tagli-h', pt: 'recorta-h', zh: '左右裁剪' },
letterbox: { en: 'letterbox', es: 'recorta-v', fr: 'taille-v',
it: 'tagli-v', pt: 'recorta-v', zh: '上下裁剪' },
text: { en: 'text', es: 'texto', fr: 'texte', it: 'testo',
pt: 'texto', zh: '文字' },
mirror: { en: 'mirror', es: 'refleja', fr: 'réfléch', it:
'rispecchi', pt: 'refleja', zh: '反射' },
freeze: { en: 'freeze', es: 'pausa', fr: 'arrête', it:
'pausa', pt: 'pausa', zh: '暂停' },
presets: { en: 'presets', es: 'estilos', fr: 'styles', it:
'stili', pt: 'estilos', zh: '预设' },
preset: { en: 'preset: ', es: 'estilo: ', fr: 'style: ', it:
'stile: ', pt: 'estilo: ', zh: '预设:' },
reset: { en: 'reset', es: 'reini', fr: 'réinit', it:
'reset', pt: 'reini', zh: '重置' },
open_tip: { en: 'Open', es: 'Abre', fr: 'Ouvre', it: 'Apri',
pt: 'Aberto', zh: '打开' },
close_tip: { en: 'Close', es: 'Cierra', fr: 'Ferme', it:
'Chiudi', pt: 'Feche', zh: '合起' },
minimize_tip: { en: 'Minimize', es: 'Minimizas', fr: 'Minimise',
it: 'Minimizzi', pt: 'Minimiza', zh: '合起' },
previews_tip: { en: ' previews', es: ' visualizaciones', fr: '
aperçus', it: ' anteprima', pt: 'visualizações', zh: '预览' },
studio_tip: { en: ' studio', es: ' estudio', fr: ' studio', it: '
studio', pt: ' estúdio', zh: '画室' },
text_tip: { en: 'Write text here', es: 'Escribe el texto aquí',
fr: 'Écrivez du texte ici', it: 'Scrivi il testo qui', pt: 'Escreva o
texto aqui', zh: '在这里写字' },
donate_tip: { en: 'Donate to the dev', es: 'Donas al dev', fr:
'Fais un don au dev', it: 'Donare al dev', pt: 'Você doa para o dev', zh:
'捐款给作者' },
}
const langs = [ 'en', 'es', 'fr', 'it', 'pt', 'zh' ]
main.lang = langs.find( x => x === navigator.language.split('-')[0] ) || 'en'
for(const key in i18n) i18n[key] = i18n[key][main.lang]
// Create inputs
const preset_values = {
reset: {},
concorde: {
contrast: 0.1,
warmth: -0.25,
tint: -0.05,
saturate: 0.2,
},
mono: {
light: 0.1,
contrast: -0.1,
sepia: 0.8,
saturate: -1,
vignette: -0.5,
},
matcha: {
light: 0.1,
tint: -0.75,
sepia: 1,
hue: 0.2,
vignette: 0.3,
fade: 0.3,
},
deepfry: {
contrast: 1,
saturate: 0.5,
}
}
String.fromCharCode(digit.charCodeAt(0) + (
// Difference in
character codes between subscript numbers and their regular equivalents.
sign === '_' ? 8272 :
// Superscript 1, 2 & 3
are in separate ranges.
digit === '1' ? 136 :
'23'.includes(digit) ?
128 : 8256
))
).join('')
)
)
})
break
case 'checkbox':
input = document.createElement('input')
input.type = 'checkbox'
input.addEventListener('change', () =>
set_value(input, input.checked)
)
break
case 'radio':
input = document.createElement('label')
input.append(...Object.keys(preset_values).map(key =>
{
const button = document.createElement('button')
button.textContent = ( key === 'reset' ) ?
i18n.reset : key
button.setAttribute('aria-label', i18n.preset +
button.textContent)
button.addEventListener('click', event => {
event.preventDefault()
Object.entries({...default_values,...preset_values[key]})
.forEach(([key, value]) =>
set_value(inputs[key], value))
})
return button
}))
break
default:
input = document.createElement('input')
input.type = 'range'
input.addEventListener('input', () => {
input.focus()
set_value(input, input.valueAsNumber)
})
input.value = value
input.id = key
label.append(input)
fields.append(label)
}
return [key, input]
})
)
// Create labels
// Mimic Google Meet tooltip behavior where hover gets priority over focused
const update_tips = () => {
tips.querySelectorAll('.show').forEach(tip=>tip.classList.remove('show'))
const show = tips.querySelector('.hover') ||
tips.querySelector('.focus')
if(show) show.classList.add('show')
}
const link_tip = ( original, tip ) => {
original.addEventListener('mouseenter',()=>{
tip.classList.add('hover')
update_tips()
})
original.addEventListener('mouseleave',()=>{
tip.classList.remove('hover')
update_tips()
})
original.addEventListener('focus',()=>{
tip.classList.add('focus')
update_tips()
})
original.addEventListener('blur',()=>{
tip.classList.remove('focus')
update_tips()
})
}
// create bottom bar
// Create previews
const previews = document.createElement('button')
previews.id = 'previews'
const toggleEdit = () => {
main.classList.remove('minimize')
main.classList.toggle('edit')
previews.focus()
const state = main.classList.contains('edit')
state ? Object.values(inputs)[0].focus() : previews.focus()
previews_tip.textContent = previews_tip.dataset[state ? 'on' : 'off']
previews_tip.classList.remove('focus')
update_tips()
}
previews.addEventListener('click', toggleEdit)
link_tip(previews,previews_tip)
// Ctrl+m to toggle
window.addEventListener('keydown', event => {
if (event.code=='KeyM' && event.ctrlKey) {
event.preventDefault()
event.shiftKey ? toggleMinimize(event) : toggleEdit(event)
}
})
// Create canvases
const canvases = Object.fromEntries(['buffer', 'freeze', 'display'].map(name
=> {
const element = document.createElement('canvas')
const context = element.getContext('2d')
return [name, {
element,
context
}]
}))
// Create title
const title = document.createElement('h2')
title.id = 'title'
title.innerText = 'Mercator\nStudio'
// Add UI to page
main.append(bar, tips, fields)
shadow.append(main, style, svg)
document.body.append(host)
const amp = 8
let task = 0
constructor(old_stream) {
super(old_stream)
video.srcObject = old_stream
const w = old_stream_settings.width
const h = old_stream_settings.height
const center = [w / 2, h / 2]
Object.values(canvases).forEach(canvas => {
canvas.element.width = w
canvas.element.height = h
})
const canvas = canvases.buffer.buffer
const context = canvases.buffer.context
const freeze = {
state: false,
init: false,
image: document.createElement('img'),
canvas: canvases.freeze,
}
inputs.freeze.addEventListener('change', e => {
freeze.state = freeze.init = e.target.checked
})
context.textAlign = 'center'
context.textBaseline = 'middle'
function draw() {
context.clearRect(0, 0, w, h)
// Get values
inputs.hue.value %= 1
inputs.rotate.value %= 1
let v = values
// Color balance
components.R.setAttribute('tableValues', polynomial_table(-
warmth + tint / 2))
components.G.setAttribute('tableValues', polynomial_table(-
tint))
components.B.setAttribute('tableValues',
polynomial_table( warmth + tint / 2))
// CSS filters
context.filter = (`
brightness(${light})
contrast(${contrast})
${'url(#filter)'.repeat(Boolean(warmth||tint))}
sepia(${sepia})
hue-rotate(${hue})
saturate(${saturate})
blur(${blur})
`)
context.fillStyle = `hsla(0,0%,${fade_lum}%,$
{fade_alpha})`
context.fillRect(0, 0, w, h)
}
vignette_gradient.addColorStop(0, `hsla(0,0%,$
{vignette_lum}%,0`)
vignette_gradient.addColorStop(1, `hsla(0,0%,$
{vignette_lum}%,${vignette_alpha}`)
context.fillStyle = vignette_gradient
context.fillRect(0, 0, w, h)
// Text:
if (text) {
char_metrics = context.measureText('0')
line_height = 1.5 *
(char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent)
context.lineWidth = font_size / 8
context.strokeStyle = 'black'
context.fillStyle = 'white'
canvases.display.context.clearRect(0, 0, w, h)
canvases.display.context.drawImage(canvases.buffer.element,
0, 0)
}
clearInterval(task)
task = setInterval(draw, 33)
const new_stream = canvases.display.element.captureStream(30)
new_stream.addEventListener('inactive', () => {
old_stream.getTracks().forEach(track => {
track.stop()
})
canvases.display.context.clearRect(0, 0, w, h)
video.srcObject = null
})
return new_stream
}
}
MediaDevices.prototype.old_getUserMedia = MediaDevices.prototype.getUserMedia
MediaDevices.prototype.getUserMedia = async constraints =>
(constraints && constraints.video && !constraints.audio) ?
new mercator_studio_MediaStream(await
navigator.mediaDevices.old_getUserMedia(constraints)) :
navigator.mediaDevices.old_getUserMedia(constraints)
})()