Aguarde...

8 de setembro de 2024

É possível converter um vídeo para CSS puro?

É possível converter um vídeo para CSS puro?

Twitter anteriormente conhecido como X, uma experiência estranha. É o lugar onde apenas os garotos mais legais ficam, ou assim dizem. Os garotos legais passam o tempo cheirando peidos e opiniões parecidas com peidos. Às vezes, adiciono as minhas à mistura, mas geralmente fico à espreita nos cantos, marinando no miasma quente.

Ele compartilha regularmente exemplos legais de animações CSS sofisticadas. No momento em que escrevo, seu foco tem sido em animações de rolagem CSS. Acho que há algumas novas propriedades que permitem reproduzir uma animação CSS com base na posição de rolagem. A Apple tem usado isso em suas páginas de marketing, ou assim diz jhehy. A propriedade parece bem poderosa.

Mas quão poderoso?

Isso me fez pensar…ele poderia reproduzir um vídeo como css puro?

uma linha de base

Antes de ficar muito louco, pode ser melhor fazer algo simples funcionar. A ideia parece ser colocar scroll-timeline-name: someKindOfName;um contêiner rolável. Então você pode colocar animation-timeline: --someKindOfName;o elemento que anima. Simples o suficiente.

Aqui está um exemplo com 25 divs que animam várias propriedades com base na rolagem.

Bem legal, a menos que… você esteja no Safari. Sim, o Safari ainda não suporta esse recurso. Pandas tristes. Uma pena porque é bem fácil de usar. Isso é bom. Imagine por enquanto que ele anima enquanto rola, tudo bem.

A maneira como esse exemplo funciona é bem simples.

#v1 {
  position: relative;
  overflow-x: hidden;
  overflow-y: scroll;
  scroll-timeline-name: --section;
}
#v1 .container {
  height: 800vh;
}
#v1 .animated-div {
  animation-timeline: --section;
  position: absolute;
  animation-name: scrolly;
  animation-fill-mode: both;
  animation-timing-function: linear;
}

Posicione absolutamente os elementos animados de rolagem dentro de uma div rolável. Adicionei outra div com uma altura fixa na visualização de rolagem para controlar quanta rolagem há para reproduzir a animação completa.

Pode valer a pena tentar um método diferente de animação via posição de rolagem que funcione em todos os navegadores. A ideia é usar um pouquinho de js para definir uma variável css para qual é a posição de rolagem atual. Isso pode ser usado para definir um atraso de animação negativo para “limpar” os quadros-chave conforme a posição de rolagem muda. Isso deve funcionar em todos os navegadores, mas tecnicamente não é css “puro”. Vou usar essa técnica daqui para frente para compatibilidade.

Aqui está o exemplo anterior com este método implementado.

O css é um pouco mais complicado que a versão da linha do tempo.

#wrapper {
  position: relative;
  overflow-x: hidden;
  overflow-y: scroll;
  --scroll: 0; // <== set via js
}
.scroll-height {
  height: 800vh;
}
.animated-div {
  position: absolute;
  width: 100px;
  height: 100px;
  animation-name: mySpicyBoyAnimation;
  animation-fill-mode: both;
  animation-timing-function: linear;
  animation-play-state: paused;
  animation-delay: calc(var(--scroll, 0) * -1s);
  animation-duration: 1s;
  box-shadow: 0 2px 22px #0008;
}

O segredo aqui é que um atraso de animação negativo agirá como se a animação tivesse começado antes. Isso significa que um atraso de -.5sfunções como se estivesse começando no meio da animação. Uma variável css pode ser usada para limpar a posição da animação e, se definida a partir de js com base na rolagem, funciona exatamente como a versão da linha do tempo. É importante pausar a animação e definir a função de temporização como linear. Você pode usar outras funções de temporização, mas todas elas pareciam… erradas para mim.

O js para definir a posição de rolagem é bem padrão.

const targetDiv = document.getElementById("container");
targetDiv.addEventListener("scroll", () => {
  const scrollTop = targetDiv.scrollTop;
  const scrollHeight = targetDiv.scrollHeight - targetDiv.clientHeight;
  const scrollPercent = scrollTop / scrollHeight;
  targetDiv.style.setProperty("--scroll", `${scrollPercent}`);
});

Este é um bom momento para testar o estresse de ambas as versões para garantir que elas não tenham um desempenho radicalmente diferente antes de prosseguir. O ideal seria ter milhões de divs, mas vou adivinhar que 1-5k é o limite superior.

Aqui estão 500 divs.

Não houve muita diferença entre usar timeline ou o hack de atraso de animação. Provavelmente atingindo um limite em um limite de animação css. Parece que o orçamento é 500.

Seria divertido definir as posições div para algo mais interessante do que números aleatórios… talvez algo mais 3D?

Que tal posicionar divs ao longo dos vértices de um modelo 3D com base na rolagem?

aventura de calças extravagantes

Carregar um modelo não é tão ruim dependendo do formato. Eu usei .objporque é fácil ignorar outros dados que não me interessam. Eu só preciso de vértices. Esta pequena função me permitirá carregar um modelo e aplicar alguma escala e rotação opcionais aos pontos. Ye’old gypity fez a função de rotação de vértice para mim.

const loadObjModel = (
  objData,
  [rx, ry, rz] = [0, 0, 0],
  [sx, sy, sz] = [1, 1, 1]
) => {
  const vertices: Array<{ x: number; y: number; z: number }> = [];
  const lines = objData.split("\n");

  lines.forEach((line) => {
    if (line.startsWith("v ")) {
      const [, x, y, z] = line.split(" ").map(Number);
      const v = rotateVertex(x, y, z, { x: rx, y: ry, z: rz });
      v.x *= sx;
      v.y *= -sy; // needed to flip y axis for js world
      v.z *= sz;
      vertices.push(v);
    }
  });

  return vertices;
};

Então, dê um tapa nessa coisa feia como pecado useMemopara gerar indexação de estilos nos modelos.

const divStyles = useMemo(
  () =>
    Array.from({ length: count })
      .map(
        (_, index) => `
          .div${index + 1} {
            --x1pos: ${cx + m1[index % m1.length].x}px;
            --y1pos: ${cy + m1[index % m1.length].y}px;
            --z1pos: ${cz + m1[index % m1.length].z}px;
            --x2pos: ${cx + rnd(-200, 200)}px;
            --y2pos: ${cy + rnd(-200, 200)}px;
            --z2pos: ${cz + rnd(-200, 200)}px;
            --x3pos: ${cx + m2[index % m2.length].x}px;
            --y3pos: ${cy + m2[index % m2.length].y + 100}px;
            --z3pos: ${cz + m2[index % m2.length].z}px;
            --x4pos: ${cx + rnd(-200, 200)}px;
            --y4pos: ${cy + rnd(-200, 200)}px;
            --z4pos: ${cz + rnd(-200, 200)}px;
            --col1: ${gen()};
            --col2: ${gen(colorPalette2)};
            --col3: ${gen()};
            --col4: ${gen(colorPalette3)};
          }
        ` 
      )
      .join(""),
  [cx, cy, cz, width, height]
);

Eu realmente não precisava usar um memorando. Ele funciona bem sem ele, mas me faz sentir mais eficiente, como se eu estivesse economizando eletricidade. A otimização prematura me faz sentir como o capitão planeta.

O exemplo parece bem legal, mas tem mais de 600 pontos e pode ser lento. Os modelos são divertidos, o mainecoon é meu favorito.

Os divs quando rotacionados podem ser vistos como finos como papel. Esse é um comportamento normal e correto ao renderizar quads rotacionados (planos 2D) em 3D. Existe uma técnica chamada billboarding que rotaciona os quads para sempre ficarem de frente para a câmera, mas não é algo embutido no CSS.

Os exemplos mostrados usaram apenas 4 quadros-chave. E se esse número fosse aumentado um pouco? Tipo, muito, um pouco…

E se um vídeo fosse reduzido e, então, para cada pixel, um div fosse animado de forma que cada quadro-chave fosse um valor de pixel correspondente a um quadro do vídeo? Seria uma porrada de quadros-chave. Megabytes de quadros-chave até. Não me arrependo do que estou prestes a fazer com seu navegador.

perto do infinito e além

Ok. Primeiro. Crie uma videoUrlToArrayOfPixelDatafunção. Como exatamente se converte um vídeo em dados de pixel? Não sei. Existem todos os tipos de codificações. O método mais simples é deixar o navegador lidar com isso. Acontece que você pode renderizar um formato de vídeo suportado pelo navegador em uma tela e então ler os pixels de volta.

Quero poder configurar uma taxa de quadros de amostra para permitir a redução opcional do número de quadros-chave usados. Isso permitiria a amostragem de 10 fps de um vídeo rodando a 24 fps, por exemplo.

Este é o código.

async function getUrlFrameData({
  url,
  targetWidth,
  targetHeight,
  scale,
  fps,
}: typestuff) {
  const video = document.createElement("video");
  video.crossOrigin = "anonymous";
  video.src = url;

  await video.load();
  await new Promise((resolve) => {
    video.addEventListener("loadedmetadata", resolve);
  });

  if (!scale) scale = 1;
  if (!targetWidth) targetWidth = video.videoWidth;
  if (!targetHeight) targetHeight = video.videoHeight;
  if (!fps) fps = 1;
  targetWidth *= scale;
  targetHeight *= scale;

  const aspectRatio = video.videoWidth / video.videoHeight;
  if (targetWidth / targetHeight > aspectRatio) {
    targetWidth = targetHeight * aspectRatio;
  } else {
    targetHeight = targetWidth / aspectRatio;
  }

  targetWidth = Math.floor(targetWidth);
  targetHeight = Math.floor(targetHeight);

  const canvas = document.createElement("canvas");
  canvas.width = targetWidth;
  canvas.height = targetHeight;
  const ctx = canvas.getContext("2d", { willReadFrequently: true });

  if (!ctx) {
    throw "failed to create context";
  }

  const frames: Uint8ClampedArray[] = [];

  video.currentTime = 0;
  const duration = video.duration;
  const interval = 1 / fps;

  while (video.currentTime < duration) {
    ctx.drawImage(video, 0, 0, targetWidth, targetHeight);
    const frameData = ctx.getImageData(0, 0, targetWidth, targetHeight).data;
    frames.push(frameData);
    video.currentTime += interval;
    await new Promise((resolve) =>
      video.addEventListener("seeked", resolve, { once: true })
    );
  }

  return { frames, width: targetWidth, height: targetHeight };
}

Seria uma boa refatoração reutilizar os elementos canvas e de vídeo. Uma observação interessante é que isso não espera o carregamento completo do vídeo. Em vez disso, ele “buscará” cada quadro do qual está sendo amostrado com base nos fps de entrada. A desvantagem é que isso acaba sendo uma cascata de solicitações, pois cada busca reinicia o fluxo. Há uma maneira de armazenar em cache vários elementos de vídeo e canvas e usá-los para tornar os quadros carregáveis ​​em paralelo.

Hoje, não sou o capitão planeta, então isso vai ficar como está.

O próximo passo é reduzir essa matriz gigante de dados de quadros brutos em várias strings de animação CSS enormes, uma para cada pixel.

const pixelKeyframes: Array<string[]> = [];

for (const frame of frames) {
  for (let x = 0; x < width; x++) {
    for (let y = 0; y < height; y++) {
      const i = y * Math.floor(width) + x;
      const fi = i * 4;
      const r = frame[fi];
      const g = frame[fi + 1];
      const b = frame[fi + 2];
      const a = frame[fi + 3];
      const hex = rgbToHex(r, g, b);
      if (!pixelKeyframes[i]) {
        pixelKeyframes[i] = [];
      }
      pixelKeyframes[i].push(hex);
    }
  }
}

const css = generateCssKeyframes(pixelKeyframes, frames.length);

Essa etapa em teoria poderia ser removida, mas achei mais fácil me afastar dos bytes brutos. A função generate key frame foi ajudada em parte pelo gypity. Digo isso porque odeio o código que ele fez, mas funcionou depois de alguns ajustes.

Nos exemplos anteriores eu estava na terra do React. Eu cansei disso e decidi criar o HTML em js puro. Eu tentei grades CSS porque eu queria escala fácil e controle de proporção de aspecto. Não funcionou bem. Em vez disso, eu tive que escrever muito js para posicionar tudo corretamente, independentemente do tamanho da fonte de vídeo, tamanho de downsampling, tamanho do contêiner ou tamanho da tela. Funciona bem, desde que você não redimensione sua janela.

Aqui está o bit que posiciona os divs e define o nome da animação.

for (let index = 0; index < width * height; index++) {
  const gridItem = document.createElement("div");
  gridItem.id = `pixel-${index}`;
  gridItem.style.animationName = `pixel-${index}`;

  const x = (index % width) * scaleX + targetWidth / 2 - (width * scaleX) / 2;
  const y = Math.floor(index / width) * scaleY + targetHeight / 2 - (height * scaleY) / 2;
  gridItem.style.left = `${x}px`;
  gridItem.style.top = `${y}px`;

  gridContainer.appendChild(gridItem);
}

Esse é um código feio pra caramba. Tenha em mente que os pixels estão em uma matriz plana, daí a indexação.

Aqui está um vídeo com um fps alvo de 5, reduzido em 98% e pixels arredondados. Pode levar alguns segundos para carregar se você estiver no Chrome.

Falando em Chrome. O Safari é muito mais rápido. Tipo 30x mais rápido, talvez mais. Ele lida com mais divs animados, mas também pode lidar com strings CSS 100x maiores. O Chrome trava bem rápido quando o fps ou a resolução aumentam. Não importa qual, pois parece que a string CSS é muito grande para o Chrome lidar. O Safari também não reinicia o fluxo de solicitação ao buscar a reprodução de vídeo, então analisar o vídeo também é muito mais rápido.

Vale mencionar que o css gerado para isso é enorme, mas não estupidamente enorme. Os exemplos acima podem atingir quase megabytes de três dígitos. Em 1080p nativo, sem falar em 4k, ele roda em muitas centenas de mb. O gargalo parece ser o tamanho da string css junto com o número de divs animados, com o Safari fazendo o melhor.

Uma ideia de otimização para reduzir o tamanho da string css é pular quadros-chave se o valor do pixel não for alterado quadro a quadro. Isso pode fornecer uma redução enorme dependendo do vídeo. Meu instinto diz que uma redução de 60-80% dependendo da qualidade desejada é uma expectativa razoável.

const keyframes: string[] = [];

pixelKeyframes.forEach((frameColors, pixelIndex) => {
  const pixelAnimation: string[] = [];
  let previousColor: string | null = null;

  frameColors.forEach((color, frameIndex) => {
    if (previousColor === null || !isColorSimilar(previousColor, color, .96)) {
      const percent = ((frameIndex / (totalFrames - 1)) * 100).toFixed(2);
      pixelAnimation.push(`${percent}% { background: ${color}; }`);
      previousColor = color;
    }
  });

  const pixelKeyframe = `
      @keyframes pixel-${pixelIndex} {
          ${pixelAnimation.join("\n")}
      }`;
  keyframes.push(pixelKeyframe);
});

return keyframes.join("\n");

Em uma verificação de similaridade de 96%, ele corta o tamanho do vídeo css pela metade. No entanto, há alguns artefatos agora. Descobri que colocar um limite no número de quadros pulados reduz os artefatos, mas não os elimina. Tenho certeza de que amostrar mais pixels ao comparar entre quadros ajudaria. No entanto, ainda não resolve o problema de ser lento para renderizar no geral.

Tipo, eu ainda preciso ter um monte de divs cuidadosamente posicionados. Cada div tem sua própria animação com keyframes. Não parece css puro. Muito html. Se ao menos houvesse algum tipo de maneira de renderizar um monte de pixels em um único div usando apenas css e keyframes. se ao menos…

fechando o círculo

Sombras de caixa de membro ? Eu membro.

Pegue um div, adicione uma string de sombra de caixa grande e anime a string em keyframes. Cada keyframe é um frame do vídeo. Isso deve ser muito mais simples do que o anterior. E será css puro. Deve ser possível colocar uma classe em um div e assistir ao vídeo ir ou rolar para animar.

Vamos ver se funciona.

Isso é muito mais rápido tanto no Chrome quanto no Safari. Ele pode lidar com quase 4x a resolução e a taxa de quadros. Curiosamente, o Chrome se sai melhor com renderização de sombra de caixa, então se ele não travar ao analisar a string CSS, acaba sendo mais suave que o Safari.

O código também está mais limpo.

  const boxShadows: string[] = [];

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const index = (y * width + x) * 4;
      const r = frameData[index];
      const g = frameData[index + 1];
      const b = frameData[index + 2];
      const hexColor = rgbToHex(r, g, b);
      boxShadows.push(`${x}px ${y}px ${hexColor}`);
    }
  }

  const step = (frameIndex / (totalFrames - 1)) * 100;
  return `${step.toFixed(2)}% {box-shadow:${boxShadows.join(",")};}`;

Criar quadros-chave de sombra de caixa é bem simples, supondo que cada sombra seja um único pixel.

A string css pura para isso é a seguinte.

const css = `
  .cssToVideo {
    position: absolute;
    top: -1px;
    left: -1px;
    overflow: visible;
    width: 1px;
    height: 1px;
    animation: cssToVideo linear ${duration}s both infinite;
    ${
      animateWithScroll ? `
      animation-duration: 1s;
      animation-delay: calc(var(--scroll, 0) * -1s);
      animation-play-state: paused;   
    ` : ``
    }
  }
  @keyframes cssToVideo {\n ${cssKeyframes.join("\n")} \n}
`;

Agora amarre essa classe bad boy cssToVideoem um div e você está pronto para as corridas. Há um pouco mais de código de colagem que adiciona divs, css, scroll listener, etc., mas o acima sozinho reproduzirá um vídeo css puro. Tão estúpido. Eu adoro isso.

Mas eu sei o que você está pensando. Agora mesmo você tem um monte de vídeos que estão implorando para serem transformados em css puro. Você quer um pequeno aplicativo onde você pode carregar um vídeo e visualizar diferentes resoluções, fps, etc, que irá cuspir uma string css para o seu vídeo, assim como todas as outras ferramentas css online. Não diga mais nada, fam.

Ok, o aplicativo foi um saco de fazer. Vou pular “como” ele foi feito. O importante é que agora você pode converter vídeos em strings css puras para usar na próxima landing page da sua startup. Não se preocupe com o fato de que a maioria dos navegadores dos seus usuários irá travar. Isso não é importante agora. O importante é que as pessoas entendam o que você representa. Você está disposto a usar a tecnologia mais esotérica e sem sentido sem nenhuma razão prática, mas para enviar uma mensagem aos seus clientes e à sua concorrência, especialmente à concorrência. Estilo sobre substância vence a batalha 50% das vezes, todas as vezes. Nunca mude.

Talvez se vídeos em CSS puro pegarem, eu atualize o aplicativo para incluir mais recursos. Talvez até mesmo criar um formato de arquivo especial para ele, chamado .vibcssjunto com um RFC, porque Deus sabe que a web precisa de mais “padrões”. Vale a pena notar que em iPhones parece haver um limite rígido para resolução, mas não tanto tamanho de CSS. Então, contanto que a resolução seja pequena o suficiente, você deve conseguir ter centenas de megabytes de CSS.

milha extra

Quando mostrei o aplicativo ao meu amigo, ele disse: “deveria despejar o playbakc em gif”. Presumi que ele quis dizer que deveria haver uma maneira de salvar seu vídeo cssificado em um gif animado. Para manter o espírito aqui, o objetivo é pegar a string css gerada anteriormente e convertê-la em um gif animado. Há uma gif.jsbiblioteca com uma API tipo canvas para criar gifs. Isso é perfeito porque todos os dados de pixel estão na string css se eu puder analisá-los. Tentei fazer com que o gypitydoodah me fornecesse um código de análise, mas falhou.

Em caso de dúvida, divida, divida, corte, mapeie, divida, corte, mapeie e, finalmente, mapeie novamente.

function extractBoxShadowColorsAsUint8Arrays(css: string) {
  return css
    .split("@keyframes cssToVideo {")[1]
    .split("box-shadow")
    .slice(1)
    .map((shadow) =>
      shadow
        .split(" #")
        .slice(1)
        .map((pixel) => pixel.split(/,|;/g)[0])
        .map((pixel) => hexToRgb(pixel))
    )
    .map((frame) => {
      const raw = new Uint8ClampedArray(frame.length * 4);
      frame.forEach((pixel, i) => {
        raw[i * 4] = pixel[0];
        raw[i * 4 + 1] = pixel[1];
        raw[i * 4 + 2] = pixel[2];
        raw[i * 4 + 3] = 255;
      });
      return raw;
    });
}

Isso é chique? Não. É frágil? Provavelmente. Funciona? Mais ou menos.

Mantive o nome original da função gyptiy extractBoxShadowColorsAsUint8Arraysporque me lembra java. sips coffee

Para suportar manter a escala de resolução de pré-visualização, preciso renderizar uma imagem para outra tela, dimensionando-a, certificando-me de desligar a suavização de imagem para manter o estilo pixelado. Pessoas de gráficos chamam esse tipo de dimensionamento de imagem de nearest neighbor.

function framesToGif(
  frames: Uint8ClampedArray[],
  originalWidth: number,
  originalHeight: number,
  scaleFactor: number,
  delay: number
) {
  return new Promise<string>((resolve, reject) => {
    const width = originalWidth * scaleFactor;
    const height = originalHeight * scaleFactor;

    const originalCanvas = document.createElement("canvas");
    const scaledCanvas = document.createElement("canvas");
    const originalCtx = originalCanvas.getContext("2d", {
      willReadFrequently: true, // <-- hint that we read...alot
    });
    const scaledCtx = scaledCanvas.getContext("2d", {
      willReadFrequently: true, // <-- idk if we read but gif.js may
    });

    originalCanvas.width = originalWidth;
    originalCanvas.height = originalHeight;
    scaledCanvas.width = width;
    scaledCanvas.height = height;
    scaledCtx.imageSmoothingEnabled = false; // <-- use nearest neighbor

    const gif = new GIF({
      workers: 2, // idk if this is good number
      quality: 10, // gpt set these values, jesus take the wheel
      width: width,
      height: height,
    });

    const imageData = originalCtx.createImageData(
      originalWidth,
      originalHeight
    );

    frames.forEach((frameData) => {
      imageData.data.set(frameData);
      originalCtx.putImageData(imageData, 0, 0);
      scaledCtx.drawImage(originalCanvas,0,0,originalWidth,originalHeight,0,0,width,height);
      gif.addFrame(scaledCtx, { delay, copy: true }); // gpt forgot the copy: true here >.> 
    });

    gif.on("finished", function (blob) {
      resolve( URL.createObjectURL(blob));
    });

    gif.render();
  });
}

Depois, juntá-los.

export async function cssToGif(
  css: string,
  width: number,
  height: number,
  scale: number,
  fps: number
) {
  const frames = extractBoxShadowColorsAsUint8Arrays(css);
  const url = await framesToGif(frames, width, height, scale, (1 / fps) * 1000);
  window.open(url);
}

Tão simples. Agora, você também pode converter um vídeo em css puro e então converter esse css em um gif. É basicamente um aplicativo de vídeo para gif. É extremamente importante que não pulemos a etapa do css puro, pois o estilo importa.

Postado em BlogTags:
Escreva um comentário