Vap Animation Concepts

A self-contained visual walkthrough of the article’s core ideas: canvas video playback, GPU texture thinking, CSS blending, hidden alpha channels, generated config, and why browser audio remains a separate problem.

1. Drawing a Playing Video Frame into a 2D Canvas

Code:
const canvas = document.querySelector('#canvas2d');
const ctx = canvas.getContext('2d');

let t = 0;

function drawVideoLikeFrame() {
  t += 0.035;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
  gradient.addColorStop(0, '#1d4ed8');
  gradient.addColorStop(1, '#020617');

  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#22d3ee';
  ctx.beginPath();
  ctx.arc(210 + Math.sin(t) * 120, 120, 42, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = '#f59e0b';
  ctx.beginPath();
  ctx.arc(410 + Math.cos(t * 1.4) * 90, 150, 34, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
  ctx.font = '700 24px system-ui';
  ctx.fillText('2D canvas redraws every frame', 142, 230);

  requestAnimationFrame(drawVideoLikeFrame);
}

drawVideoLikeFrame();
Result:

2. Treating Frames as WebGL Textures

Code:
const canvas = document.querySelector('#webglTexture');
const gl = canvas.getContext('webgl');

const vertexShaderSource = `
attribute vec2 position;
varying vec2 uv;

void main() {
  uv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

const fragmentShaderSource = `
precision mediump float;
varying vec2 uv;
uniform float time;

void main() {
  vec3 color = vec3(0.03, 0.07, 0.14);
  float glow = 0.0;

  glow += 0.18 / distance(uv, vec2(0.5 + sin(time) * 0.28, 0.45));
  glow += 0.12 / distance(uv, vec2(0.34, 0.55 + cos(time * 1.6) * 0.18));

  color += vec3(glow * 0.2, glow * 0.8, glow);
  gl_FragColor = vec4(color, 1.0);
}
`;

function compile(type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  return shader;
}

const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexShaderSource));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentShaderSource));
gl.linkProgram(program);
gl.useProgram(program);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
  gl.STATIC_DRAW
);

const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

const timeUniform = gl.getUniformLocation(program, 'time');

function render(time) {
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.uniform1f(timeUniform, time * 0.001);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  requestAnimationFrame(render);
}

requestAnimationFrame(render);
Result:

3. CSS Screen Blend as a Cheap Transparency Trick

Code:
<div class="blend-demo">
  <div class="subject"></div>
  <div class="caption">mix-blend-mode: screen keeps bright effects and loses dark parts</div>
</div>

<style>
.blend-demo {
  position: relative;
  min-height: 260px;
  padding: 26px;
  background:
    linear-gradient(135deg, #19324d, #391638),
    repeating-linear-gradient(90deg, transparent 0 48px, rgba(255, 255, 255, 0.08) 48px 50px);
}

.blend-demo .subject {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 220px;
  height: 142px;
  border-radius: 8px;
  transform: translate(-50%, -50%);
  background:
    radial-gradient(circle at 22% 38%, #ffffff 0 8%, transparent 9%),
    radial-gradient(circle at 76% 58%, #38bdf8 0 9%, transparent 10%),
    linear-gradient(90deg, #020617 0 24%, #f8fafc 24% 52%, #111827 52% 76%, #fbbf24 76%);
  mix-blend-mode: screen;
  animation: pulse 1.8s ease-in-out infinite alternate;
}
</style>
Result:
mix-blend-mode: screen keeps bright effects and loses dark parts

4. Packing RGB and Alpha into One Video Frame

Code:
const canvas = document.querySelector('#alphaPacked');
const ctx = canvas.getContext('2d');
const alphaSlider = document.querySelector('#alphaStrength');

function drawPackedAlphaDemo() {
  const alphaStrength = Number(alphaSlider.value) / 100;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#0f172a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#1d4ed8';
  ctx.fillRect(0, 0, 340, 280);

  ctx.fillStyle = '#020617';
  ctx.fillRect(340, 0, 340, 280);

  ctx.fillStyle = '#f97316';
  ctx.beginPath();
  ctx.arc(150, 136, 66, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = '#22c55e';
  ctx.beginPath();
  ctx.arc(220, 122, 48, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = 'white';
  ctx.font = '700 18px system-ui';
  ctx.fillText('RGB color area', 98, 254);

  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(490, 136, 66, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = `rgba(255, 255, 255, ${alphaStrength})`;
  ctx.beginPath();
  ctx.arc(560, 122, 48, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = 'white';
  ctx.fillText('Alpha mask area', 430, 254);
}

alphaSlider.addEventListener('input', drawPackedAlphaDemo);
drawPackedAlphaDemo();
Result:

5. Decoding Hidden Alpha Back into Real Transparency

Code:
const canvas = document.querySelector('#decodedAlpha');
const ctx = canvas.getContext('2d');
const toggle = document.querySelector('#toggleDecoded');

function drawDecodedAlpha() {
  const useAlpha = toggle.checked;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#334155';
  for (let y = 0; y < canvas.height; y += 28) {
    for (let x = 0; x < canvas.width; x += 28) {
      ctx.fillStyle = (x + y) % 56 === 0 ? '#e2e8f0' : '#cbd5e1';
      ctx.fillRect(x, y, 28, 28);
    }
  }

  const shapes = [
    { x: 260, y: 122, r: 74, color: '#f97316', alpha: 0.95 },
    { x: 360, y: 126, r: 58, color: '#22c55e', alpha: useAlpha ? 0.42 : 1 },
    { x: 320, y: 170, r: 42, color: '#38bdf8', alpha: useAlpha ? 0.18 : 1 }
  ];

  shapes.forEach(shape => {
    ctx.globalAlpha = shape.alpha;
    ctx.fillStyle = shape.color;
    ctx.beginPath();
    ctx.arc(shape.x, shape.y, shape.r, 0, Math.PI * 2);
    ctx.fill();
  });

  ctx.globalAlpha = 1;
  ctx.fillStyle = '#0f172a';
  ctx.font = '700 20px system-ui';
  ctx.fillText(useAlpha ? 'Decoded with alpha' : 'Raw opaque color only', 236, 252);
}

toggle.addEventListener('change', drawDecodedAlpha);
drawDecodedAlpha();
Result:

6. Reading Vap-Like Config to Crop RGB and Alpha Regions

Code:
const config = {
  info: {
    v: 2,
    f: 12,
    w: 454,
    h: 340,
    fps: 24,
    videoW: 464,
    videoH: 528,
    aFrame: [0, 344, 227, 170],
    rgbFrame: [0, 0, 454, 340],
    isVapx: 0,
    orien: 0
  }
};

const table = document.querySelector('#configTable');
const rows = [
  ['display size', `${config.info.w} × ${config.info.h}`],
  ['video texture', `${config.info.videoW} × ${config.info.videoH}`],
  ['rgbFrame', config.info.rgbFrame.join(', ')],
  ['aFrame', config.info.aFrame.join(', ')],
  ['fps', config.info.fps]
];

table.innerHTML = rows
  .map(([key, value]) => `<tr><th>${key}</th><td>${value}</td></tr>`)
  .join('');
Result:
Generated MP4 layout
RGB
ALPHA
vapc.json fields

7. Simulating the Vap Constructor Call

Code:
class MiniVap {
  constructor(options) {
    this.container = options.container;
    this.width = options.width;
    this.height = options.height;
    this.fps = options.fps;
    this.frame = 0;
    this.canvas = document.createElement('canvas');
    this.canvas.width = this.width;
    this.canvas.height = this.height;
    this.container.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');
    this.play();
  }

  play() {
    this.frame += 1;
    const t = this.frame / this.fps;

    this.ctx.clearRect(0, 0, this.width, this.height);
    this.ctx.fillStyle = '#07111f';
    this.ctx.fillRect(0, 0, this.width, this.height);

    this.ctx.globalAlpha = 0.85;
    this.ctx.fillStyle = '#38bdf8';
    this.ctx.beginPath();
    this.ctx.arc(185 + Math.sin(t * 3) * 90, 134, 58, 0, Math.PI * 2);
    this.ctx.fill();

    this.ctx.globalAlpha = 0.48;
    this.ctx.fillStyle = '#f97316';
    this.ctx.beginPath();
    this.ctx.arc(300 + Math.cos(t * 2.2) * 70, 156, 72, 0, Math.PI * 2);
    this.ctx.fill();

    this.ctx.globalAlpha = 1;
    this.ctx.fillStyle = '#fff';
    this.ctx.font = '700 22px system-ui';
    this.ctx.fillText('new Vap({ container, src, config, fps })', 58, 282);

    requestAnimationFrame(() => this.play());
  }
}

new MiniVap({
  container: document.querySelector('#miniVapContainer'),
  src: './images/output/video.mp4',
  width: 454,
  height: 340,
  config: { info: { w: 454, h: 340, fps: 24 } },
  fps: 24
});
Result:

8. Why Picture and Sound Are Separate Concerns

Code:
const status = document.querySelector('#audioStatus');
const button = document.querySelector('#audioButton');

button.addEventListener('click', async () => {
  const AudioContext = window.AudioContext || window.webkitAudioContext;
  const audio = new AudioContext();

  const oscillator = audio.createOscillator();
  const gain = audio.createGain();

  oscillator.frequency.value = 220;
  gain.gain.setValueAtTime(0.0001, audio.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.18, audio.currentTime + 0.04);
  gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.45);

  oscillator.connect(gain);
  gain.connect(audio.destination);
  oscillator.start();
  oscillator.stop(audio.currentTime + 0.5);

  status.textContent = 'Audio was started by a user gesture.';
});
Result:

Canvas/WebGL draws pixels. Audio still needs browser playback permission.

Visual layer ≠ audio layer