"use client";

import type { Ajustes } from "./preferencias";
import type { Segmentador } from "./segmentador";

/*
 * O processamento da câmera, todo na placa de vídeo do aparelho (WebGL2):
 *
 *   câmera → quadro → [máscara da pessoa] → fundo embaçado + filtros → trilha nova
 *
 * No Chrome e no Edge o quadro vem pelo MediaStreamTrackProcessor e sai pelo
 * MediaStreamTrackGenerator: anda no ritmo da câmera e continua com a aba em
 * segundo plano (quem apresenta e abre outra aba não congela). Nos outros
 * navegadores, um <video> escondido e o canvas.captureStream.
 *
 * Nada vai para o servidor: o servidor só recebe a trilha pronta.
 */

export type Parametros = Ajustes & { desfoque: number };

export type Estatisticas = { fps: number; msPorQuadro: number; msMascara: number; nivel: number; largura: number; altura: number; caminho: "insertable" | "canvas" };

/* Os tipos do Insertable Streams ainda não estão no lib.dom do TypeScript. */
type ProcessadorDeTrilha = { readable: ReadableStream<VideoFrame> };
type GeradorDeTrilha = MediaStreamTrack & { writable: WritableStream<VideoFrame> };
type JanelaComInsertable = typeof globalThis & {
  MediaStreamTrackProcessor?: new (o: { track: MediaStreamTrack; maxBufferSize?: number }) => ProcessadorDeTrilha;
  MediaStreamTrackGenerator?: new (o: { kind: "video" }) => GeradorDeTrilha;
};

type Fonte = VideoFrame | HTMLVideoElement;

/* O maior quadro que sai (a live manda no máximo 720p). */
const LARGURA_MAXIMA = 1280;
const ALTURA_MAXIMA = 720;

/* Cada nível troca qualidade por velocidade: de quantos em quantos quadros acha a pessoa, e o tamanho do desenho. */
const NIVEIS = [
  { cada: 1, escala: 1 },
  { cada: 2, escala: 1 },
  { cada: 3, escala: 0.75 },
  { cada: 3, escala: 0.5 },
];

function aparelhoFraco(): boolean {
  try {
    const nucleos = navigator.hardwareConcurrency || 4;
    const celular = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
    return nucleos <= 4 || celular;
  } catch {
    return false;
  }
}

/* ------------------------------------------------------------- shaders */

const VERTICES = `#version 300 es
in vec2 aPos;
out vec2 vUv;
uniform float uInverter;
void main() {
  vUv = aPos * 0.5 + 0.5;
  if (uInverter > 0.5) vUv.y = 1.0 - vUv.y;
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

/* Reduz o quadro a 1/4. Com máscara, só o fundo entra (a pessoa não "vaza" no desfoque). */
const REDUZIR = `#version 300 es
precision mediump float;
in vec2 vUv;
out vec4 cor;
uniform sampler2D uQuadro;
uniform sampler2D uMascara;
uniform vec2 uPasso;
uniform float uComMascara;
void main() {
  vec3 c = (texture(uQuadro, vUv + uPasso * vec2(-1.0, -1.0)).rgb
          + texture(uQuadro, vUv + uPasso * vec2( 1.0, -1.0)).rgb
          + texture(uQuadro, vUv + uPasso * vec2(-1.0,  1.0)).rgb
          + texture(uQuadro, vUv + uPasso * vec2( 1.0,  1.0)).rgb) * 0.25;
  float peso = 1.0;
  if (uComMascara > 0.5) peso = 1.0 - smoothstep(0.12, 0.5, texture(uMascara, vUv).r);
  cor = vec4(c * peso, peso);
}`;

/* Desfoque gaussiano numa direção (9 amostras, espaçadas conforme a força). */
const BORRAR = `#version 300 es
precision mediump float;
in vec2 vUv;
out vec4 cor;
uniform sampler2D uTex;
uniform vec2 uDirecao;
void main() {
  vec4 s = texture(uTex, vUv) * 0.227027;
  s += (texture(uTex, vUv + uDirecao * 1.0) + texture(uTex, vUv - uDirecao * 1.0)) * 0.1945946;
  s += (texture(uTex, vUv + uDirecao * 2.0) + texture(uTex, vUv - uDirecao * 2.0)) * 0.1216216;
  s += (texture(uTex, vUv + uDirecao * 3.0) + texture(uTex, vUv - uDirecao * 3.0)) * 0.054054;
  s += (texture(uTex, vUv + uDirecao * 4.0) + texture(uTex, vUv - uDirecao * 4.0)) * 0.016216;
  cor = s;
}`;

/* O quadro final: pele, luz, cor e o fundo embaçado atrás da pessoa. */
const FINAL = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 cor;
uniform sampler2D uQuadro;
uniform sampler2D uMascara;
uniform sampler2D uFundo;
uniform sampler2D uLuz;
uniform vec2 uTexel;
uniform vec2 uTexelMascara;
uniform float uComMascara;
uniform float uDesfoque;
uniform float uSuavizar;
uniform float uIluminar;
uniform float uBrilho;
uniform float uQuente;
uniform float uRosado;
uniform float uRaio;

/* Tom de pele (YCbCr), de qualquer cor de pele: ignora o muito escuro. */
float pele(vec3 c) {
  float y = dot(c, vec3(0.299, 0.587, 0.114));
  float cb = 0.5 - 0.168736 * c.r - 0.331264 * c.g + 0.5 * c.b;
  float cr = 0.5 + 0.5 * c.r - 0.418688 * c.g - 0.081312 * c.b;
  float s = smoothstep(0.26, 0.31, cb) * (1.0 - smoothstep(0.49, 0.53, cb))
          * smoothstep(0.505, 0.54, cr) * (1.0 - smoothstep(0.68, 0.72, cr));
  return s * smoothstep(0.07, 0.18, y);
}

/* A máscara com a borda macia (sem serrilhado nem contorno duro). */
float mascara(vec2 uv) {
  vec2 d = uTexelMascara * 1.5;
  float m = texture(uMascara, uv).r * 0.4
    + (texture(uMascara, uv + vec2(d.x, 0.0)).r + texture(uMascara, uv - vec2(d.x, 0.0)).r
     + texture(uMascara, uv + vec2(0.0, d.y)).r + texture(uMascara, uv - vec2(0.0, d.y)).r) * 0.15;
  return smoothstep(0.3, 0.72, m);
}

/* Alisa sem borrar o contorno: só mistura vizinhos de cor parecida (filtro bilateral). */
vec3 alisar(vec2 uv, vec3 c) {
  vec3 soma = c;
  float pesos = 1.0;
  for (int i = 0; i < 12; i++) {
    float a = float(i) * 0.5235988 + 0.26;
    vec2 dir = vec2(cos(a), sin(a)) * uRaio * uTexel;
    vec3 s1 = texture(uQuadro, uv + dir * 0.5).rgb;
    vec3 s2 = texture(uQuadro, uv + dir).rgb;
    vec3 d1 = s1 - c;
    vec3 d2 = s2 - c;
    float w1 = exp(-dot(d1, d1) * 80.0);
    float w2 = exp(-dot(d2, d2) * 80.0) * 0.75;
    soma += s1 * w1 + s2 * w2;
    pesos += w1 + w2;
  }
  return soma / pesos;
}

void main() {
  vec3 c = texture(uQuadro, vUv).rgb;
  float m = uComMascara > 0.5 ? mascara(vUv) : 1.0;
  vec3 p = c;
  float sk = 0.0;
  if (uSuavizar > 0.001 || uRosado > 0.001) {
    sk = pele(c);
    if (uComMascara > 0.5) sk *= smoothstep(0.35, 0.85, m);
  }
  if (uSuavizar > 0.001 && sk > 0.01) {
    vec3 liso = alisar(vUv, c);
    /* Guarda um pouco do detalhe (poros, barba): pele suave, não plástico. */
    p = mix(c, liso + (c - liso) * 0.22, clamp(sk * uSuavizar, 0.0, 0.92));
  }
  if (uIluminar > 0.001) {
    /* Levanta sombras e meios-tons sem estourar o claro. */
    vec3 claro = 1.0 - (1.0 - p) * (1.0 - p);
    p = mix(p, claro, uIluminar * 0.38);
  }
  if (uBrilho > 0.001) {
    vec3 g = texture(uLuz, vUv).rgb;
    vec3 tela = 1.0 - (1.0 - p) * (1.0 - g);
    p = mix(p, tela, uBrilho * 0.32);
  }
  if (uQuente > 0.001) p += vec3(0.065, 0.018, -0.055) * uQuente;
  if (uRosado > 0.001) p = mix(p, p * vec3(1.06, 0.95, 0.99) + vec3(0.03, 0.0, 0.016), uRosado * (0.35 + 0.65 * sk));
  p = clamp(p, 0.0, 1.0);
  if (uDesfoque > 0.5 && uComMascara > 0.5) {
    vec4 f = texture(uFundo, vUv);
    vec3 fundo = f.a > 0.03 ? f.rgb / f.a : c;
    p = mix(fundo, p, m);
  }
  cor = vec4(p, 1.0);
}`;

/* ------------------------------------------------------------- WebGL */

type Programa = { prog: WebGLProgram; u: (nome: string) => WebGLUniformLocation | null };
type Alvo = { fbo: WebGLFramebuffer; tex: WebGLTexture; w: number; h: number };

function compilar(gl: WebGL2RenderingContext, fonteDosVertices: string, fonteDosPontos: string): Programa {
  const sombra = (tipo: number, fonte: string) => {
    const s = gl.createShader(tipo);
    if (!s) throw new Error("WebGL sem shader");
    gl.shaderSource(s, fonte);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(`shader: ${gl.getShaderInfoLog(s) ?? ""}`);
    return s;
  };
  const prog = gl.createProgram();
  if (!prog) throw new Error("WebGL sem programa");
  gl.attachShader(prog, sombra(gl.VERTEX_SHADER, fonteDosVertices));
  gl.attachShader(prog, sombra(gl.FRAGMENT_SHADER, fonteDosPontos));
  gl.bindAttribLocation(prog, 0, "aPos");
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(`programa: ${gl.getProgramInfoLog(prog) ?? ""}`);
  const cache = new Map<string, WebGLUniformLocation | null>();
  return {
    prog,
    u: (nome) => {
      if (!cache.has(nome)) cache.set(nome, gl.getUniformLocation(prog, nome));
      return cache.get(nome) ?? null;
    },
  };
}

function novaTextura(gl: WebGL2RenderingContext): WebGLTexture {
  const t = gl.createTexture();
  if (!t) throw new Error("WebGL sem textura");
  gl.bindTexture(gl.TEXTURE_2D, t);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return t;
}

function novoAlvo(gl: WebGL2RenderingContext, w: number, h: number): Alvo {
  const tex = novaTextura(gl);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  const fbo = gl.createFramebuffer();
  if (!fbo) throw new Error("WebGL sem framebuffer");
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  return { fbo, tex, w, h };
}

/* ------------------------------------------------------------- o processador */

export class ProcessadorDeCamera {
  readonly saida: MediaStreamTrack;
  readonly caminho: "insertable" | "canvas";
  /* A placa de vídeo caiu (ou outro erro sem volta): quem usa volta para a câmera crua. */
  aoFalhar: (motivo: string) => void = () => undefined;

  private readonly copia: MediaStreamTrack;
  private readonly tela: OffscreenCanvas | HTMLCanvasElement;
  private readonly gl: WebGL2RenderingContext;
  private readonly progReduzir: Programa;
  private readonly progBorrar: Programa;
  private readonly progFinal: Programa;
  private readonly texQuadro: WebGLTexture;
  private readonly texMascara: WebGLTexture;
  private alvos: { fundoA: Alvo; fundoB: Alvo; luzA: Alvo; luzB: Alvo } | null = null;
  private parametros: Parametros = { desfoque: 0, suavizar: 0, iluminar: 0, brilho: 0, quente: 0, rosado: 0 };
  private segmentador: Segmentador | null = null;
  private suave: Float32Array | null = null;
  private mascaraBytes: Uint8Array | null = null;
  private mascaraTam = { w: 0, h: 0 };
  private temMascara = false;
  private encerrado = false;
  private falhou = false;
  private contador = 0;
  private nivel: number;
  private readonly nivelInicial: number;
  private ultimaTroca = 0;
  /* Medidas (média móvel). */
  private msQuadro = 0;
  private msMascara = 0;
  private quadrosNoSegundo = 0;
  private inicioDoSegundo = 0;
  private fps = 0;
  private larguraSaida = 0;
  private alturaSaida = 0;

  /* Caminho do Chrome. */
  private leitor: ReadableStreamDefaultReader<VideoFrame> | null = null;
  /* Caminho dos outros navegadores. */
  private video: HTMLVideoElement | null = null;
  private relogio: ReturnType<typeof setTimeout> | null = null;

  constructor(bruta: MediaStreamTrack) {
    this.nivelInicial = aparelhoFraco() ? 1 : 0;
    this.nivel = this.nivelInicial;
    /* Os primeiros quadros (compilar os shaders, aquecer) não contam para economizar. */
    this.ultimaTroca = performance.now();
    /* Uma cópia da trilha: parar o processamento nunca desliga a câmera de verdade. */
    this.copia = bruta.clone();
    this.copia.enabled = true;
    const j = globalThis as JanelaComInsertable;
    const insertable = Boolean(j.MediaStreamTrackProcessor && j.MediaStreamTrackGenerator && typeof VideoFrame !== "undefined" && typeof OffscreenCanvas !== "undefined");
    this.caminho = insertable ? "insertable" : "canvas";
    this.tela = insertable ? new OffscreenCanvas(2, 2) : document.createElement("canvas");
    const gl = (this.tela as HTMLCanvasElement).getContext("webgl2", {
      alpha: false,
      antialias: false,
      depth: false,
      stencil: false,
      premultipliedAlpha: false,
      preserveDrawingBuffer: true,
      powerPreference: "high-performance",
    }) as WebGL2RenderingContext | null;
    if (!gl) {
      this.copia.stop();
      throw new Error("Sem WebGL2");
    }
    this.gl = gl;
    try {
      this.progReduzir = compilar(gl, VERTICES, REDUZIR);
      this.progBorrar = compilar(gl, VERTICES, BORRAR);
      this.progFinal = compilar(gl, VERTICES, FINAL);
      const buf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(0);
      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
      this.texQuadro = novaTextura(gl);
      this.texMascara = novaTextura(gl);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, 1, 1, 0, gl.RED, gl.UNSIGNED_BYTE, new Uint8Array([255]));
    } catch (erro) {
      this.copia.stop();
      throw erro;
    }
    (this.tela as HTMLCanvasElement).addEventListener?.("webglcontextlost", (e: Event) => {
      e.preventDefault();
      this.falhar("A placa de vídeo parou de responder.");
    });

    if (insertable) {
      const Processador = j.MediaStreamTrackProcessor!;
      const Gerador = j.MediaStreamTrackGenerator!;
      const gerador = new Gerador({ kind: "video" });
      this.saida = gerador;
      const processador = new Processador({ track: this.copia, maxBufferSize: 1 });
      this.leitor = processador.readable.getReader();
      void this.rodarInsertable(gerador.writable.getWriter());
    } else {
      const video = document.createElement("video");
      video.muted = true;
      video.playsInline = true;
      video.autoplay = true;
      video.srcObject = new MediaStream([this.copia]);
      void video.play().catch(() => undefined);
      this.video = video;
      const tela = this.tela as HTMLCanvasElement;
      const s = bruta.getSettings();
      tela.width = Math.min(LARGURA_MAXIMA, s.width || 640);
      tela.height = Math.min(ALTURA_MAXIMA, s.height || 360);
      const trilha = tela.captureStream(30).getVideoTracks()[0];
      if (!trilha) {
        this.copia.stop();
        throw new Error("captureStream sem trilha");
      }
      this.saida = trilha;
      this.rodarCanvas();
    }
  }

  ajustar(p: Parametros) {
    this.parametros = p;
  }

  usarSegmentador(s: Segmentador | null) {
    this.segmentador = s;
    if (!s) {
      this.temMascara = false;
      this.suave = null;
    }
  }

  estatisticas(): Estatisticas {
    return {
      fps: this.fps,
      msPorQuadro: Math.round(this.msQuadro * 10) / 10,
      msMascara: Math.round(this.msMascara * 10) / 10,
      nivel: this.nivel,
      largura: this.larguraSaida,
      altura: this.alturaSaida,
      caminho: this.caminho,
    };
  }

  encerrar() {
    if (this.encerrado) return;
    this.encerrado = true;
    if (this.relogio) clearTimeout(this.relogio);
    void this.leitor?.cancel().catch(() => undefined);
    this.copia.stop();
    this.saida.stop();
    if (this.video) {
      this.video.pause();
      this.video.srcObject = null;
    }
    (this.gl.getExtension("WEBGL_lose_context") as { loseContext?: () => void } | null)?.loseContext?.();
  }

  private falhar(motivo: string) {
    if (this.falhou || this.encerrado) return;
    this.falhou = true;
    this.aoFalhar(motivo);
  }

  /* --------------------------------------------------------- os laços */

  private async rodarInsertable(escritor: WritableStreamDefaultWriter<VideoFrame>) {
    const leitor = this.leitor!;
    try {
      for (;;) {
        const { value: quadro, done } = await leitor.read();
        if (done || !quadro) break;
        if (this.encerrado) {
          quadro.close();
          break;
        }
        let saida: VideoFrame | null = null;
        try {
          const inicio = performance.now();
          if (this.desenhar(quadro, quadro.displayWidth, quadro.displayHeight)) {
            saida = new VideoFrame(this.tela as OffscreenCanvas, { timestamp: quadro.timestamp, alpha: "discard" });
            /* Conta também a cópia do desenho para o quadro novo: é ali que a placa de vídeo cobra. */
            this.medir(performance.now() - inicio);
          }
        } catch (erro) {
          console.warn("[live] efeitos", erro);
          this.falhar("Os efeitos pararam neste aparelho.");
        } finally {
          quadro.close();
        }
        if (saida) {
          try {
            await escritor.write(saida);
          } catch {
            saida.close();
            break;
          }
        }
      }
    } catch {
      /* a trilha acabou */
    }
  }

  private rodarCanvas() {
    const video = this.video!;
    let ultimoTempo = -1;
    const passo = () => {
      if (this.encerrado) return;
      try {
        if (video.readyState >= 2 && video.videoWidth && video.currentTime !== ultimoTempo) {
          ultimoTempo = video.currentTime;
          const inicio = performance.now();
          if (this.desenhar(video, video.videoWidth, video.videoHeight)) this.medir(performance.now() - inicio);
        }
      } catch (erro) {
        console.warn("[live] efeitos", erro);
        this.falhar("Os efeitos pararam neste aparelho.");
        return;
      }
      this.relogio = setTimeout(passo, 1000 / 30);
    };
    this.relogio = setTimeout(passo, 0);
  }

  /* --------------------------------------------------------- o desenho */

  /* Desenha um quadro; devolve se desenhou. */
  private desenhar(fonte: Fonte, larguraFonte: number, alturaFonte: number): boolean {
    if (this.falhou || !larguraFonte || !alturaFonte) return false;
    const gl = this.gl;
    const p = this.parametros;
    const nivel = NIVEIS[this.nivel];

    /* O tamanho que sai: o da câmera, até 720p, menor nos níveis de economia. */
    const caber = Math.min(1, LARGURA_MAXIMA / larguraFonte, ALTURA_MAXIMA / alturaFonte);
    const minimo = Math.min(1, 480 / larguraFonte);
    const escala = Math.max(minimo, caber * nivel.escala);
    const w = Math.max(2, Math.round((larguraFonte * escala) / 2) * 2);
    const h = Math.max(2, Math.round((alturaFonte * escala) / 2) * 2);
    if (this.tela.width !== w || this.tela.height !== h) {
      this.tela.width = w;
      this.tela.height = h;
    }
    this.larguraSaida = w;
    this.alturaSaida = h;
    const pw = Math.max(2, Math.ceil(w / 4));
    const ph = Math.max(2, Math.ceil(h / 4));
    if (!this.alvos || this.alvos.fundoA.w !== pw || this.alvos.fundoA.h !== ph) this.criarAlvos(pw, ph);
    const alvos = this.alvos!;

    /* O quadro da câmera vira textura. */
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, this.texQuadro);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, fonte);

    /* A pessoa no quadro (de tantos em tantos quadros, conforme o aparelho aguenta). */
    const querDesfoque = p.desfoque > 0.001;
    let msMascara = 0;
    if (this.segmentador && (querDesfoque || p.suavizar > 0)) {
      this.contador += 1;
      if (!this.temMascara || this.contador % nivel.cada === 0) {
        const t0 = performance.now();
        const r = this.segmentador.segmentar(fonte, t0);
        msMascara = performance.now() - t0;
        this.msMascara = this.msMascara ? this.msMascara * 0.9 + msMascara * 0.1 : msMascara;
        if (r) this.receberMascara(r.dados, r.largura, r.altura);
      }
    }
    const comMascara = this.temMascara && Boolean(this.segmentador);

    gl.disable(gl.BLEND);

    /* Fundo embaçado: reduz (só o fundo), borra na horizontal e na vertical. */
    if (querDesfoque && comMascara) {
      this.passoReduzir(alvos.fundoA, true, w, h);
      const espaco = 0.6 + p.desfoque * 2.6;
      const vezes = p.desfoque > 0.55 ? 2 : 1;
      for (let i = 0; i < vezes; i++) {
        this.passoBorrar(alvos.fundoA.tex, alvos.fundoB, espaco / pw, 0);
        this.passoBorrar(alvos.fundoB.tex, alvos.fundoA, 0, espaco / ph);
      }
    }
    /* A luz suave do filtro "Iluminado": o quadro inteiro, pequeno e borrado. */
    if (p.brilho > 0.001) {
      this.passoReduzir(alvos.luzA, false, w, h);
      this.passoBorrar(alvos.luzA.tex, alvos.luzB, 2.2 / pw, 0);
      this.passoBorrar(alvos.luzB.tex, alvos.luzA, 0, 2.2 / ph);
    }

    /* O final, na tela que vira trilha. */
    const f = this.progFinal;
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, w, h);
    gl.useProgram(f.prog);
    gl.uniform1f(f.u("uInverter"), 1);
    this.ligarTextura(0, this.texQuadro, f.u("uQuadro"));
    this.ligarTextura(1, this.texMascara, f.u("uMascara"));
    this.ligarTextura(2, alvos.fundoA.tex, f.u("uFundo"));
    this.ligarTextura(3, alvos.luzA.tex, f.u("uLuz"));
    gl.uniform2f(f.u("uTexel"), 1 / larguraFonte, 1 / alturaFonte);
    gl.uniform2f(f.u("uTexelMascara"), 1 / Math.max(1, this.mascaraTam.w), 1 / Math.max(1, this.mascaraTam.h));
    gl.uniform1f(f.u("uComMascara"), comMascara ? 1 : 0);
    gl.uniform1f(f.u("uDesfoque"), querDesfoque ? 1 : 0);
    gl.uniform1f(f.u("uSuavizar"), p.suavizar);
    gl.uniform1f(f.u("uIluminar"), p.iluminar);
    gl.uniform1f(f.u("uBrilho"), p.brilho);
    gl.uniform1f(f.u("uQuente"), p.quente);
    gl.uniform1f(f.u("uRosado"), p.rosado);
    /* O raio do alisamento acompanha o tamanho da câmera (o mesmo rosto em 360p e em 720p). */
    gl.uniform1f(f.u("uRaio"), (alturaFonte / 720) * (5 + 5 * p.suavizar));
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    if (gl.isContextLost()) {
      this.falhar("A placa de vídeo parou de responder.");
      return false;
    }
    return true;
  }

  private ligarTextura(unidade: number, tex: WebGLTexture, local: WebGLUniformLocation | null) {
    const gl = this.gl;
    gl.activeTexture(gl.TEXTURE0 + unidade);
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.uniform1i(local, unidade);
  }

  private passoReduzir(alvo: Alvo, comMascara: boolean, w: number, h: number) {
    const gl = this.gl;
    const r = this.progReduzir;
    gl.bindFramebuffer(gl.FRAMEBUFFER, alvo.fbo);
    gl.viewport(0, 0, alvo.w, alvo.h);
    gl.useProgram(r.prog);
    gl.uniform1f(r.u("uInverter"), 0);
    this.ligarTextura(0, this.texQuadro, r.u("uQuadro"));
    this.ligarTextura(1, this.texMascara, r.u("uMascara"));
    gl.uniform2f(r.u("uPasso"), 1 / w, 1 / h);
    gl.uniform1f(r.u("uComMascara"), comMascara ? 1 : 0);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  }

  private passoBorrar(origem: WebGLTexture, destino: Alvo, dx: number, dy: number) {
    const gl = this.gl;
    const b = this.progBorrar;
    gl.bindFramebuffer(gl.FRAMEBUFFER, destino.fbo);
    gl.viewport(0, 0, destino.w, destino.h);
    gl.useProgram(b.prog);
    gl.uniform1f(b.u("uInverter"), 0);
    this.ligarTextura(0, origem, b.u("uTex"));
    gl.uniform2f(b.u("uDirecao"), dx, dy);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  }

  private criarAlvos(w: number, h: number) {
    const gl = this.gl;
    if (this.alvos) {
      for (const a of Object.values(this.alvos)) {
        gl.deleteFramebuffer(a.fbo);
        gl.deleteTexture(a.tex);
      }
    }
    this.alvos = { fundoA: novoAlvo(gl, w, h), fundoB: novoAlvo(gl, w, h), luzA: novoAlvo(gl, w, h), luzB: novoAlvo(gl, w, h) };
  }

  /*
   * A máscara nova entra devagar onde muda pouco (some o tremido da borda) e
   * de uma vez onde muda muito (a pessoa se mexeu: sem rastro).
   */
  private receberMascara(dados: Uint8Array, w: number, h: number) {
    const n = w * h;
    if (!this.suave || this.suave.length !== n || !this.mascaraBytes) {
      this.suave = new Float32Array(n);
      this.mascaraBytes = new Uint8Array(n);
      for (let i = 0; i < n; i++) this.suave[i] = dados[i] / 255;
    } else {
      const s = this.suave;
      for (let i = 0; i < n; i++) {
        const atual = dados[i] / 255;
        const dif = Math.abs(atual - s[i]);
        const k = 0.45 + 0.55 * Math.min(1, dif * 2.5);
        s[i] += (atual - s[i]) * k;
      }
    }
    const bytes = this.mascaraBytes;
    const s = this.suave;
    for (let i = 0; i < n; i++) bytes[i] = (s[i] * 255) | 0;
    const gl = this.gl;
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, this.texMascara);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, bytes);
    this.mascaraTam = { w, h };
    this.temMascara = true;
  }

  /* Mede e, se o aparelho não está dando conta, economiza (e volta quando sobra fôlego). */
  private recomecarMedida(agora: number) {
    this.ultimaTroca = agora;
    this.msQuadro = 0;
    this.fps = 0;
    this.quadrosNoSegundo = 0;
    this.inicioDoSegundo = agora;
  }

  private medir(ms: number) {
    this.msQuadro = this.msQuadro ? this.msQuadro * 0.92 + ms * 0.08 : ms;
    const agora = performance.now();
    if (!this.inicioDoSegundo) this.inicioDoSegundo = agora;
    this.quadrosNoSegundo += 1;
    if (agora - this.inicioDoSegundo >= 1000) {
      this.fps = Math.round((this.quadrosNoSegundo * 1000) / (agora - this.inicioDoSegundo));
      this.quadrosNoSegundo = 0;
      this.inicioDoSegundo = agora;
    }
    if (agora - this.ultimaTroca < 2500) return;
    /* O ritmo da câmera (20 ou 30 por segundo): abaixo de 3/4 dele, o aparelho não está dando conta. */
    const alvo = Math.min(30, this.copia.getSettings().frameRate || 30);
    const lento = this.msQuadro > 30 || (this.fps > 0 && this.fps < alvo * 0.75);
    if (lento && this.nivel < NIVEIS.length - 1) {
      this.nivel += 1;
      this.recomecarMedida(agora);
    } else if (this.msQuadro < 12 && this.fps >= alvo * 0.9 && this.nivel > this.nivelInicial && agora - this.ultimaTroca > 8000) {
      this.nivel -= 1;
      this.recomecarMedida(agora);
    }
  }
}
