import { ArrayBufferTarget, Muxer } from "mp4-muxer";
import { PARADO, type EstadoNaCena } from "./animacao";
import { OBTURADOR, audiosDoProjeto, centroDa, fadesDoAudio, desenharNoEstado, duracaoDaPagina, estadosDaTransicao, estadosNoTempo, intervaloDoClipe, paginaNoTempo, temposDoProjeto, transicaoNoTempo, unidadesDaCena, type UnidadeDaCena } from "./cena";
import { aplicarChroma } from "./chroma";
import { maisPerto, numeroDoTexto, valorNoTempo, valoresParaFotografar } from "./contador";
import { velocidadeDo, type DocumentoDoBanner, type PecaDeImagem, type PecaDeTextura } from "./documento";
import { desenharParticulas, ehParticula, particulasNoTempo } from "./particulas";
import { comPalavras } from "@/components/pagina-inicial/editor-de-banner/palavras";
import type { TipoDaCamada } from "./video-camadas";

/*
 * O vídeo do projeto (MP4, H.264 + som), feito no navegador, igual ao que a
 * linha do tempo do editor toca: as cenas uma depois da outra com as
 * transições; em cada cena, o servidor fotografa as camadas
 * (/api/artes/video/camadas) e aqui cada uma entra com a animação dela
 * (cena.ts, a mesma conta do editor), quadro a quadro num canvas. A peça de
 * vídeo desenha o quadro do arquivo no instante certo (por cima da capa, no
 * mesmo lugar), e o som dos vídeos que não estão mudos é misturado no tempo
 * de cada clipe. O codificador do próprio navegador (WebCodecs) grava.
 * Precisa de Chrome, Edge ou Safari 17+.
 */

type Camada = { ids: string[]; fundo: boolean; tipo: TipoDaCamada; caixa: { x: number; y: number; w: number; h: number }; imagem: string };
type Resposta = { largura: number; altura: number; escala: number; camadas: Camada[]; error?: string };
/* Cada unidade com a foto dela; as partículas são desenhadas a cada quadro; o contador tem uma foto por valor. */
type UnidadePreparada = UnidadeDaCena & { bitmap: ImageBitmap; particulas?: PecaDeTextura; contador?: { valores: number[]; fotos: Map<number, ImageBitmap> } };
type CenaPreparada = { pagina: DocumentoDoBanner; largura: number; altura: number; escala: number; unidades: UnidadePreparada[]; videos: Map<string, HTMLVideoElement> };

export class VideoSemSuporte extends Error {}

/** O navegador consegue gravar o MP4 aqui? */
export function videoComSuporte() {
  return typeof window !== "undefined" && "VideoEncoder" in window && "VideoFrame" in window;
}

async function codecQueServe(largura: number, altura: number, fps: number) {
  for (const codec of ["avc1.640028", "avc1.4d0028", "avc1.42e028", "avc1.640033"]) {
    const config: VideoEncoderConfig = { codec, width: largura, height: altura, bitrate: Math.round(largura * altura * fps * 0.12), framerate: fps, avc: { format: "avc" } };
    const resposta = await VideoEncoder.isConfigSupported(config).catch(() => null);
    if (resposta?.supported) return config;
  }
  return null;
}

/* ------------------------------------------------------------- vídeos */

function esperar(alvo: HTMLVideoElement, evento: string, ms: number) {
  return new Promise<boolean>((ok) => {
    const tempo = window.setTimeout(() => ok(false), ms);
    alvo.addEventListener(
      evento,
      () => {
        window.clearTimeout(tempo);
        ok(true);
      },
      { once: true },
    );
  });
}

async function abrirVideo(src: string) {
  const v = document.createElement("video");
  v.crossOrigin = "anonymous";
  v.muted = true;
  v.playsInline = true;
  v.preload = "auto";
  const pronto = esperar(v, "loadeddata", 30000);
  v.src = src;
  if (!(await pronto)) throw new Error("Um dos vídeos da arte não abriu. Confira a internet e tente de novo.");
  return v;
}

/* Leva o vídeo ao segundo `t` (espera o quadro chegar). */
async function irPara(v: HTMLVideoElement, t: number, fps: number) {
  const alvo = Math.min(Math.max(0, v.duration - 0.05), Math.max(0, t));
  if (Math.abs(v.currentTime - alvo) < 0.5 / fps) return;
  const buscou = esperar(v, "seeked", 5000);
  v.currentTime = alvo;
  await buscou;
}

/* Uma tela do tamanho da camada, reaproveitada (o quadro do vídeo, as partículas, as amostras do borrão). */
const telas = new Map<string, OffscreenCanvas>();
function telaLimpa(nome: string, largura: number, altura: number) {
  const [w, h] = [Math.round(largura), Math.round(altura)];
  let tela = telas.get(nome);
  if (!tela || tela.width !== w || tela.height !== h) {
    tela = new OffscreenCanvas(w, h);
    telas.set(nome, tela);
  }
  const ctx = tela.getContext("2d")!;
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = "source-over";
  ctx.filter = "none";
  ctx.clearRect(0, 0, w, h);
  return { tela, ctx };
}

/*
 * O quadro do vídeo no lugar da peça (com o giro, o canto arredondado, o
 * enquadre de cobrir ou conter, os ajustes e o fundo verde tirado), numa
 * camada do tamanho da página: a animação da unidade vem depois, pelo mesmo
 * caminho das outras camadas (3D, máscaras e borrão incluídos).
 */
function pintarVideo(ctx: OffscreenCanvasRenderingContext2D, v: HTMLVideoElement, p: PecaDeImagem, escala: number) {
  if (!v.videoWidth) return;
  ctx.save();
  ctx.globalAlpha = p.opacidade;
  const [x, y, w, h] = [p.x * escala, p.y * escala, p.w * escala, p.h * escala];
  ctx.translate(x + w / 2, y + h / 2);
  ctx.rotate((p.rot * Math.PI) / 180);
  ctx.scale(p.espelharX ? -1 : 1, p.espelharY ? -1 : 1);
  const raio = p.circulo ? Math.min(w, h) / 2 : Math.min(p.raio * escala, Math.min(w, h) / 2);
  ctx.beginPath();
  if (p.circulo) ctx.ellipse(0, 0, w / 2, h / 2, 0, 0, Math.PI * 2);
  else ctx.roundRect(-w / 2, -h / 2, w, h, raio);
  ctx.clip();
  const f = p.filtros;
  const filtro = [f.brilho !== 100 ? `brightness(${f.brilho}%)` : "", f.contraste !== 100 ? `contrast(${f.contraste}%)` : "", f.saturacao !== 100 ? `saturate(${f.saturacao}%)` : "", f.desfoque ? `blur(${f.desfoque * escala}px)` : ""].filter(Boolean).join(" ");
  if (filtro) ctx.filter = filtro;
  const proporcao = v.videoWidth / v.videoHeight;
  const cobrir = p.ajuste === "cobrir";
  let [dw, dh] = w / h > proporcao === cobrir ? [w, w / proporcao] : [h * proporcao, h];
  if (!cobrir && (dw > w || dh > h)) [dw, dh] = w / h > proporcao ? [h * proporcao, h] : [w, w / proporcao];
  if (p.chroma) {
    /* O fundo verde: a mesma conta do filtro do editor, pixel a pixel no quadro. */
    const { tela, ctx: c } = telaLimpa("chroma", Math.max(1, dw), Math.max(1, dh));
    c.drawImage(v, 0, 0, tela.width, tela.height);
    const pixels = c.getImageData(0, 0, tela.width, tela.height);
    aplicarChroma(pixels.data, p.chroma);
    c.putImageData(pixels, 0, 0);
    ctx.drawImage(tela, -dw / 2, -dh / 2, dw, dh);
  } else ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
  ctx.restore();
}

/* As partículas do instante na camada (na caixa da peça, com o giro e a transparência dela). */
function pintarParticulas(ctx: OffscreenCanvasRenderingContext2D, p: PecaDeTextura, local: number, escala: number) {
  if (!ehParticula(p.estilo)) return;
  ctx.save();
  ctx.globalAlpha = p.opacidade;
  ctx.translate((p.x + p.w / 2) * escala, (p.y + p.h / 2) * escala);
  ctx.rotate((p.rot * Math.PI) / 180);
  ctx.scale(escala, escala);
  ctx.translate(-p.w / 2, -p.h / 2);
  ctx.beginPath();
  ctx.rect(0, 0, p.w, p.h);
  ctx.clip();
  desenharParticulas(ctx, particulasNoTempo({ ...p, estilo: p.estilo }, local));
  ctx.restore();
}

/* Quanto a unidade andou (px da arte) entre dois estados: o deslocamento, o crescimento, o giro e o 3D. */
function quantoAndou(a: EstadoNaCena, b: EstadoNaCena, lado: number): number {
  const partes: number = a.partes && b.partes ? Math.max(0, ...a.partes.map((p, i) => (b.partes![i] ? quantoAndou(p.estado, b.partes![i].estado, Math.max(p.caixa.w, p.caixa.h)) : 0))) : 0;
  const giro = ((Math.abs(a.giro - b.giro) + Math.abs(a.rx - b.rx) + Math.abs(a.ry - b.ry)) * Math.PI) / 180;
  return Math.max(partes, Math.hypot(a.dx - b.dx, a.dy - b.dy) + Math.abs(a.escala - b.escala) * lado * 0.5 + giro * lado * 0.5);
}

/* -------------------------------------------------------------- som */

/** O som dos vídeos que não estão mudos, misturado no tempo de cada clipe (null se nenhum tem som). */
async function somDoProjeto(paginas: DocumentoDoBanner[], total: number): Promise<AudioBuffer | null> {
  const { inicios } = temposDoProjeto(paginas);
  const clipes = paginas.flatMap((pagina, i) => {
    const duracao = duracaoDaPagina(pagina);
    return pagina.pecas
      .filter((p): p is PecaDeImagem => p.tipo === "imagem" && Boolean(p.video) && !p.video!.mudo && !p.oculta && p.video!.volume > 0)
      .map((p) => {
        const { comeco, fim } = intervaloDoClipe(p.animacao, duracao);
        return { src: p.video!.src, quando: inicios[i] + comeco, corte: p.video!.corte, dura: fim - comeco, volume: p.video!.volume, fades: [0, 0] as [number, number], velocidade: velocidadeDo(p.video) };
      });
  });
  /* As músicas, os efeitos e os áudios enviados da trilha de áudio. */
  for (const a of audiosDoProjeto(paginas)) {
    if (a.volume <= 0 || a.inicio >= total) continue;
    const dura = Math.min(a.dura, total - a.inicio);
    clipes.push({ src: a.src, quando: a.inicio, corte: a.corte, dura, volume: a.volume, fades: fadesDoAudio({ ...a, dura }), velocidade: 1 });
  }
  if (!clipes.length || typeof OfflineAudioContext === "undefined") return null;
  const taxa = 48000;
  const mesa = new OfflineAudioContext(2, Math.ceil(total * taxa), taxa);
  const decodificados = new Map<string, AudioBuffer | null>();
  let algum = false;
  for (const c of clipes) {
    if (!decodificados.has(c.src)) {
      const bruto = await fetch(c.src)
        .then((r) => r.arrayBuffer())
        .catch(() => null);
      decodificados.set(c.src, bruto ? await mesa.decodeAudioData(bruto).catch(() => null) : null);
    }
    const som = decodificados.get(c.src);
    /* Vídeo congelado não tem som; mais rápido ou mais lento, o som acompanha (o tom muda junto, como no CapCut sem "manter o tom"). */
    if (!som || c.corte >= som.duration || c.velocidade <= 0) continue;
    const fonte = mesa.createBufferSource();
    fonte.buffer = som;
    fonte.playbackRate.value = c.velocidade;
    const ganho = mesa.createGain();
    const dura = Math.min(c.dura, (som.duration - c.corte) / c.velocidade);
    /* O fade in e o fade out (o mesmo do tocador do editor, volumeNoTempo). */
    const [entra, sai] = [Math.min(c.fades[0], dura / 2), Math.min(c.fades[1], dura / 2)];
    ganho.gain.setValueAtTime(entra > 0 ? 0 : c.volume, c.quando);
    if (entra > 0) ganho.gain.linearRampToValueAtTime(c.volume, c.quando + entra);
    if (sai > 0) {
      ganho.gain.setValueAtTime(c.volume, c.quando + dura - sai);
      ganho.gain.linearRampToValueAtTime(0, c.quando + dura);
    }
    fonte.connect(ganho).connect(mesa.destination);
    fonte.start(c.quando, c.corte, dura * c.velocidade);
    algum = true;
  }
  return algum ? mesa.startRendering() : null;
}

async function codecDeSom(): Promise<{ config: AudioEncoderConfig; muxer: "aac" | "opus" } | null> {
  if (typeof AudioEncoder === "undefined") return null;
  for (const [codec, muxer] of [
    ["mp4a.40.2", "aac"],
    ["opus", "opus"],
  ] as const) {
    const config: AudioEncoderConfig = { codec, sampleRate: 48000, numberOfChannels: 2, bitrate: 128000 };
    const ok = await AudioEncoder.isConfigSupported(config).catch(() => null);
    if (ok?.supported) return { config, muxer };
  }
  return null;
}

/* ------------------------------------------------------------ gravar */

/**
 * Grava o vídeo das páginas (todas em sequência; uma só = o vídeo dela).
 * `aoAndar` recebe a fase e quanto já foi (0 a 1). `limite` encolhe o lado
 * maior (a prévia dos modelos animados). `linkPublico` (o código do link da
 * arte): as camadas vêm pela rota do link, que lê a arte do banco (quem abre
 * não tem conta) e só recebe o número da página. Volta o MP4 pronto para baixar.
 */
export async function gravarVideo(paginas: DocumentoDoBanner[], opcoes: { aoAndar?: (fase: string, fracao: number) => void; limite?: number; semSom?: boolean; linkPublico?: string } = {}): Promise<Blob> {
  if (!videoComSuporte()) throw new VideoSemSuporte("Este navegador não grava vídeo. Use o Chrome ou o Edge atualizado.");
  if (!paginas.length) throw new Error("O projeto está vazio.");
  const fps = 30;
  const { total } = temposDoProjeto(paginas);

  /* As camadas de cada cena (o servidor fotografa uma cena por vez). */
  const pedirCamadas = (i: number, pagina: DocumentoDoBanner, extra: Record<string, unknown> = {}) =>
    opcoes.linkPublico
      ? fetch(`/api/artes/publica/${encodeURIComponent(opcoes.linkPublico)}/camadas`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pagina: i, limite: opcoes.limite, ...extra }) })
      : fetch("/api/artes/video/camadas", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ documento: pagina, largura: pagina.largura, altura: pagina.altura, limite: opcoes.limite, ...extra }) });
  const cenas: CenaPreparada[] = [];
  for (const [i, pagina] of paginas.entries()) {
    opcoes.aoAndar?.(paginas.length > 1 ? `Preparando a cena ${i + 1} de ${paginas.length}…` : "Preparando as camadas da arte…", i / paginas.length / 4);
    const resposta = await pedirCamadas(i, pagina);
    const dados = (await resposta.json().catch(() => ({}))) as Resposta;
    if (!resposta.ok) throw new Error(dados.error || "Não deu para preparar o vídeo.");
    /* As palavras medidas aqui, no navegador, com a mesma quebra de linha da camada. */
    const unidades = comPalavras(pagina, unidadesDaCena(pagina));
    const porIds = new Map(unidades.map((u) => [u.ids.join(","), u]));
    const comBitmap: UnidadePreparada[] = await Promise.all(
      dados.camadas.map(async (c) => {
        const u = porIds.get(c.ids.join(",")) ?? { ids: c.ids, fundo: c.fundo, caixa: c.caixa, animacao: null, linhas: 1, nome: "" };
        const sozinha = u.ids.length === 1 ? pagina.pecas.find((p) => p.id === u.ids[0]) : undefined;
        return { ...u, bitmap: await createImageBitmap(await (await fetch(c.imagem)).blob()), ...(sozinha?.tipo === "textura" && ehParticula(sozinha.estilo) ? { particulas: sozinha } : {}) };
      }),
    );
    /* O número que sobe: o servidor fotografa o texto em cada valor da contagem. */
    for (const u of comBitmap) {
      const peca = u.animacao?.contador && u.ids.length === 1 ? pagina.pecas.find((p) => p.id === u.ids[0]) : undefined;
      const molde = peca?.tipo === "texto" ? numeroDoTexto(peca.texto) : null;
      if (!peca || !molde || !u.animacao?.contador) continue;
      const valores = valoresParaFotografar(u.animacao.contador, molde, fps);
      const r = await pedirCamadas(i, pagina, { contador: { id: peca.id, valores } });
      const d = (await r.json().catch(() => ({}))) as { variantes?: Array<{ valor: number; imagem: string }>; error?: string };
      if (!r.ok || !d.variantes) throw new Error(d.error || "Não deu para preparar o número que sobe.");
      const fotos = new Map<number, ImageBitmap>();
      for (const v of d.variantes) fotos.set(v.valor, await createImageBitmap(await (await fetch(v.imagem)).blob()));
      u.contador = { valores: d.variantes.map((v) => v.valor), fotos };
    }
    const videos = new Map<string, HTMLVideoElement>();
    for (const p of pagina.pecas) if (p.tipo === "imagem" && p.video && !p.oculta) videos.set(p.id, await abrirVideo(p.video.src));
    cenas.push({ pagina, largura: dados.largura, altura: dados.altura, escala: dados.escala, unidades: comBitmap, videos });
  }
  const { largura, altura } = cenas[0];

  const config = await codecQueServe(largura, altura, fps);
  if (!config) throw new VideoSemSuporte("Este navegador não grava vídeo nesse tamanho. Use o Chrome ou o Edge atualizado.");
  opcoes.aoAndar?.("Preparando o som…", 0.25);
  const som = opcoes.semSom ? null : await somDoProjeto(paginas, total);
  const codecDoSom = som ? await codecDeSom() : null;
  const alvo = new ArrayBufferTarget();
  const muxer = new Muxer({ target: alvo, video: { codec: "avc", width: largura, height: altura, frameRate: fps }, ...(som && codecDoSom ? { audio: { codec: codecDoSom.muxer, numberOfChannels: 2, sampleRate: 48000 } } : {}), fastStart: "in-memory" });
  let falhou: Error | null = null;
  const aoFalhar = (e: unknown) => {
    falhou = e instanceof Error ? e : new Error(String(e));
  };
  const codificador = new VideoEncoder({ output: (pedaco, meta) => muxer.addVideoChunk(pedaco, meta), error: aoFalhar });
  codificador.configure(config);

  const tela = new OffscreenCanvas(largura, altura);
  const ctx = tela.getContext("2d")!;
  /* Cada cena é desenhada numa tela dela e depois posta na final (com a transição). */
  const daCena = new OffscreenCanvas(largura, altura);
  const ctxDaCena = daCena.getContext("2d")!;
  const ultimoQuadro = new Map<number, OffscreenCanvas>();

  const desenharCena = async (alvoCtx: OffscreenCanvasRenderingContext2D, k: number, local: number) => {
    const c = cenas[k];
    alvoCtx.save();
    alvoCtx.clearRect(0, 0, largura, altura);
    alvoCtx.fillStyle = "#FFFFFF";
    alvoCtx.fillRect(0, 0, largura, altura);
    /* Cena de outro tamanho: cabe inteira no quadro. */
    const f = Math.min(largura / c.largura, altura / c.altura);
    alvoCtx.translate((largura - c.largura * f) / 2, (altura - c.altura * f) / 2);
    alvoCtx.scale(f, f);
    const estados = estadosNoTempo(c.pagina, c.unidades, local);
    const antes = local > OBTURADOR ? estadosNoTempo(c.pagina, c.unidades, local - OBTURADOR) : null;
    const duracao = duracaoDaPagina(c.pagina);
    for (const [i, u] of c.unidades.entries()) {
      const centro = centroDa(u, c.pagina);
      /* A imagem da unidade neste instante: as partículas desenhadas agora, o número do instante ou a foto de sempre. */
      let imagem: CanvasImageSource = u.bitmap;
      if (u.particulas) {
        const { tela, ctx } = telaLimpa(`p${i}`, c.largura, c.altura);
        pintarParticulas(ctx, u.particulas, local, c.escala);
        imagem = tela;
      } else if (u.contador && u.animacao?.contador) {
        const peca = c.pagina.pecas.find((p) => p.id === u.ids[0]);
        const molde = peca?.tipo === "texto" ? numeroDoTexto(peca.texto) : null;
        const fim = u.animacao.contador.inicio + u.animacao.contador.duracao;
        if (molde && local < fim) imagem = u.contador.fotos.get(maisPerto(u.contador.valores, valorNoTempo(u.animacao.contador, molde, local))) ?? u.bitmap;
      }
      /* Os vídeos da unidade: o quadro do instante numa camada, que anda junto com a unidade. */
      let camadaDoVideo: OffscreenCanvas | null = null;
      for (const id of u.ids) {
        const v = c.videos.get(id);
        const p = c.pagina.pecas.find((x) => x.id === id) as PecaDeImagem | undefined;
        if (!v || !p?.video) continue;
        const { comeco, fim } = intervaloDoClipe(p.animacao, duracao);
        if (local < comeco || local >= fim) continue;
        await irPara(v, p.video.corte + (local - comeco) * velocidadeDo(p.video), fps);
        const ja: OffscreenCanvas | null = camadaDoVideo;
        const alvo: { tela: OffscreenCanvas; ctx: OffscreenCanvasRenderingContext2D } = ja ? { tela: ja, ctx: ja.getContext("2d")! } : telaLimpa(`v${i}`, c.largura, c.altura);
        pintarVideo(alvo.ctx, v, p, c.escala);
        camadaDoVideo = alvo.tela;
      }
      const pintar = (ctx: OffscreenCanvasRenderingContext2D, e: EstadoNaCena) => {
        desenharNoEstado(ctx, imagem, e, centro, u.caixa, c.escala, c.largura, c.altura);
        if (camadaDoVideo) desenharNoEstado(ctx, camadaDoVideo, e, centro, u.caixa, c.escala, c.largura, c.altura);
      };
      /*
       * O borrão de movimento de verdade: quem andou mais de 2 px no meio
       * quadro é desenhado em várias amostras ao longo do obturador, somadas
       * (a média), como uma câmera de cinema.
       */
      const andou = antes ? quantoAndou(estados[i], antes[i], Math.max(u.caixa.w, u.caixa.h)) * c.escala : 0;
      if (andou < 2 || u.fundo) {
        pintar(alvoCtx, estados[i]);
        continue;
      }
      const amostras = Math.max(3, Math.min(10, Math.ceil(andou / 4)));
      const { tela: soma, ctx: somaCtx } = telaLimpa("borrao", c.largura, c.altura);
      somaCtx.globalCompositeOperation = "lighter";
      for (let k = 0; k < amostras; k += 1) {
        const tk = local - (OBTURADOR * k) / (amostras - 1);
        somaCtx.save();
        somaCtx.globalAlpha = 1 / amostras;
        pintar(somaCtx, k === 0 ? estados[i] : estadosNoTempo(c.pagina, c.unidades, tk)[i]);
        somaCtx.restore();
      }
      alvoCtx.drawImage(soma, 0, 0, c.largura, c.altura);
    }
    alvoCtx.restore();
  };

  const quadros = Math.max(1, Math.round(total * fps));
  for (let quadro = 0; quadro < quadros; quadro += 1) {
    if (falhou) throw falhou;
    const t = quadro / fps;
    const { indice, local } = paginaNoTempo(paginas, t);
    const tr = transicaoNoTempo(paginas, t);
    await desenharCena(ctxDaCena, indice, local);
    /* Atrás da cena que vira em 3D, preto (como o CapCut); nas outras, branco. */
    ctx.fillStyle = tr?.tipo === "virar" ? "#000000" : "#FFFFFF";
    ctx.fillRect(0, 0, largura, altura);
    const caixa = { x: 0, y: 0, w: largura, h: altura };
    const centro: [number, number] = [largura / 2, altura / 2];
    if (tr) {
      /* A cena de antes, parada no último quadro, sai; a nova entra. */
      let antes = ultimoQuadro.get(tr.de);
      if (!antes) {
        antes = new OffscreenCanvas(largura, altura);
        await desenharCena(antes.getContext("2d")!, tr.de, duracaoDaPagina(paginas[tr.de]));
        ultimoQuadro.set(tr.de, antes);
      }
      const e = estadosDaTransicao(tr.tipo, tr.fracao, largura);
      desenharNoEstado(ctx, antes, e.antes, centro, caixa, 1, largura, altura);
      desenharNoEstado(ctx, daCena, e.depois, centro, caixa, 1, largura, altura);
      if (e.clarao > 0) {
        ctx.globalAlpha = e.clarao;
        ctx.fillStyle = e.corDoClarao;
        ctx.fillRect(0, 0, largura, altura);
        ctx.globalAlpha = 1;
      }
    } else desenharNoEstado(ctx, daCena, PARADO, centro, caixa, 1, largura, altura);
    const frame = new VideoFrame(tela, { timestamp: Math.round((quadro * 1e6) / fps), duration: Math.round(1e6 / fps) });
    codificador.encode(frame, { keyFrame: quadro % (fps * 2) === 0 });
    frame.close();
    /* Não deixa a fila do codificador crescer (memória) e solta a página a cada quadro (a barra de progresso anda, a aba não congela). */
    while (codificador.encodeQueueSize > 8) await new Promise((r) => setTimeout(r, 4));
    await new Promise((r) => setTimeout(r, 0));
    if (quadro % 10 === 0) opcoes.aoAndar?.("Gravando o vídeo…", 0.25 + (0.7 * quadro) / quadros);
  }
  await codificador.flush();
  codificador.close();
  if (falhou) throw falhou;

  if (som && codecDoSom) {
    opcoes.aoAndar?.("Juntando o som…", 0.96);
    const codificadorDeSom = new AudioEncoder({ output: (pedaco, meta) => muxer.addAudioChunk(pedaco, meta), error: aoFalhar });
    codificadorDeSom.configure(codecDoSom.config);
    const passo = 4800;
    const esquerda = som.getChannelData(0);
    const direita = som.numberOfChannels > 1 ? som.getChannelData(1) : esquerda;
    for (let i = 0; i < som.length; i += passo) {
      const n = Math.min(passo, som.length - i);
      const dados = new Float32Array(n * 2);
      dados.set(esquerda.subarray(i, i + n), 0);
      dados.set(direita.subarray(i, i + n), n);
      const pedaco = new AudioData({ format: "f32-planar", sampleRate: som.sampleRate, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round((i / som.sampleRate) * 1e6), data: dados });
      codificadorDeSom.encode(pedaco);
      pedaco.close();
    }
    await codificadorDeSom.flush();
    codificadorDeSom.close();
    if (falhou) throw falhou;
  }
  muxer.finalize();
  for (const c of cenas) {
    c.unidades.forEach((u) => u.bitmap.close());
    c.videos.forEach((v) => {
      v.removeAttribute("src");
      v.load();
    });
  }
  opcoes.aoAndar?.("Pronto", 1);
  return new Blob([alvo.buffer], { type: "video/mp4" });
}
