DEV Community

Cover image for Test Video Transparency in CI with FFmpeg and Node.js
river
river

Posted on AI-assisted

Test Video Transparency in CI with FFmpeg and Node.js

A video looks cut out in a preview. Then you place it over a dark page, and a rectangle appears around the presenter. Before an export reaches production, the useful question is whether decoded pixels actually let the new background show through.

Start with a real example. The images below come from an original podcast clip and its processed transparent WebM, decoded locally. The same output frame is shown on a checkerboard, a dark background, and a light background.

Real podcast footage before removal and the actual decoded transparent output, composited on checkerboard, dark and light backgrounds.

Public BG Remover Online demo, using stock footage from Mixkit: “Bearded announcer, close up”. The person shown is not affiliated. The images are actual frames requested at 0.4 seconds; comparison backgrounds are added here. The processing also removed the foreground microphone.

An independent run of the inspector reported transparency-detected for this 1920 × 1080, 3.753-second VP9 file. At all four requested sample times—0, 0.93825, 1.8765, and 2.81475 seconds—the decoded alpha range included both 0 and 255. Each sample also contained partially transparent pixels. These observations establish sampled alpha presence, not a whole-clip matting-quality score.

With the source checkout and FFmpeg below installed, reproduce that check on the public file:

curl -fL https://bgremover.online/demo/podcast-after-alpha.webm -o podcast-alpha.webm
node dist/cli.js podcast-alpha.webm --json --require-transparency
Enter fullscreen mode Exit fullscreen mode

Real footage shows why the check matters. For a regression test, we also need inputs with known intent: a useful cutout, an opaque video, an invisible video, and a brief alpha change between sample times. The rest of this tutorial creates those controls and turns the sampled condition into a CI gate.

Decide what a passing export means

For a reusable cutout, there are at least two requirements:

  1. Some sampled pixels let the background show through.
  2. Some sampled pixels remain visible.

That policy rejects an entirely invisible clip. It also differs from “the format supports alpha” and “this player shows the correct result.”

The experiment uses video-alpha-inspector, a standalone Node.js wrapper around local FFmpeg decoding. It reports metadata separately from pixel observations.

Its default 8-bit alpha rules are:

Rule Default
Transparent pixel Alpha below 245
Visible pixel Alpha above 10
Significant amount At least 1% of sampled pixels, rounded up
Requested times 0%, 25%, 50%, and 75% of reported duration

The two pixel categories intentionally overlap: a partly transparent pixel can still be visible. The aggregate passing status can also combine transparency in one sampled frame with visibility in another. If a pipeline requires both in the same frame, it needs a stricter per-sample policy.

Reproduce the run

Use Node.js 22 or newer, plus FFmpeg and ffprobe on PATH. The FFmpeg build needs the libvpx-vp9 encoder and decoder for this experiment.

npm publication is pending, so use the source checkout:

git clone https://github.com/j-river/video-alpha-inspector.git
cd video-alpha-inspector
git checkout a20d1844535111b139ca7ec2524c5c944f897906
npm ci --ignore-scripts
npm run build
Enter fullscreen mode Exit fullscreen mode

The results below were produced on Windows with Node.js 24.13.1 and FFmpeg/ffprobe 8.0.1. Different FFmpeg builds should be checked rather than assumed equivalent.

Generate controls with known intent

Save this as make-fixtures.mjs in the repository root:

Open the fixture generator: four synthetic videos
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';

const directory = process.argv[2] ?? 'alpha-fixtures';
mkdirSync(directory, { recursive: true });

function generate(name, duration, filter) {
  const result = spawnSync('ffmpeg', [
    '-nostdin', '-hide_banner', '-loglevel', 'error',
    '-f', 'lavfi', '-i',
    `color=c=blue:s=64x64:r=10:d=${duration},format=rgba`,
    '-vf', filter + ',format=yuva420p',
    '-c:v', 'libvpx-vp9', '-lossless', '1',
    '-auto-alt-ref', '0', '-g', '9999', '-threads', '1',
    '-deadline', 'realtime', '-y', join(directory, name),
  ], { encoding: 'utf8', windowsHide: true, timeout: 60_000 });
  if (result.error) throw result.error;
  if (result.status !== 0) throw new Error(result.stderr);
}

generate('cutout.webm', 1,
  'colorchannelmixer=aa=0,drawbox=x=16:y=16:w=32:h=32:color=blue@1:t=fill:replace=1');
generate('tagged-opaque.webm', 1, 'colorchannelmixer=aa=1');
generate('empty.webm', 1, 'colorchannelmixer=aa=0');
generate('brief-alpha.webm', 4,
  "colorchannelmixer=aa=0.5:enable='between(t,1.2,1.7)'");

console.log('Generated four synthetic VP9-alpha fixtures.');
Enter fullscreen mode Exit fullscreen mode

Run:

node make-fixtures.mjs
Enter fullscreen mode Exit fullscreen mode

The files contain synthetic colors and a square, with no downloaded footage:

  • cutout.webm: an opaque square on a transparent canvas.
  • tagged-opaque.webm: a fully opaque alpha plane.
  • empty.webm: alpha zero everywhere.
  • brief-alpha.webm: a four-second clip with partial alpha only around 1.2–1.7 seconds.

The generator enables the alpha encoding path for every control. The inspection below checks what actually survived encoding.

These commands overwrite the named files inside alpha-fixtures. Keep that folder separate from source media.

Actual decoded synthetic controls on light and dark backgrounds: visible cutout, opaque video, invisible video, and a partial-alpha frame.

The blue squares are deliberately simple regression controls, separate from the real footage above. The fourth column uses a focused request near 1.4 seconds.

Inspect pixels, then compare the outcomes

Save this as inspect-fixtures.mjs in the same repository root:

Open the inspection script and assertions
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { inspectVideoAlpha } from './dist/index.js';

const directory = process.argv[2] ?? 'alpha-fixtures';
const expected = {
  'cutout.webm': 'transparency-detected',
  'tagged-opaque.webm': 'no-transparency-observed',
  'empty.webm': 'fully-transparent-samples',
  'brief-alpha.webm': 'no-transparency-observed',
};
const rows = [];
for (const [file, status] of Object.entries(expected)) {
  const report = await inspectVideoAlpha(join(directory, file));
  assert.equal(report.status, status);
  if (file === 'cutout.webm') {
    assert.ok(report.samples.every(sample => sample.alpha.min === 0 && sample.alpha.max === 255));
  } else if (file === 'empty.webm') {
    assert.ok(report.samples.every(sample => sample.alpha.min === 0 && sample.alpha.max === 0));
  } else {
    assert.ok(report.samples.every(sample => sample.alpha.min === 255 && sample.alpha.max === 255));
  }
  rows.push({
    file,
    alphaMetadata: report.video.alphaMetadata,
    pixelFormat: report.video.pixelFormat,
    status: report.status,
    alphaMin: report.samples[0].alpha.min,
    alphaMax: report.samples[0].alpha.max,
    samples: report.samples.length,
  });
}

const focused = await inspectVideoAlpha(
  join(directory, 'brief-alpha.webm'),
  { sampleFractions: [0.35] },
);
assert.equal(focused.status, 'transparency-detected');

console.log(JSON.stringify({
  rows,
  focused: {
    fraction: 0.35,
    requestedTimeSeconds: focused.samples[0].requestedTimeSeconds,
    status: focused.status,
    alphaMin: focused.samples[0].alpha.min,
    alphaMax: focused.samples[0].alpha.max,
  },
}, null, 2));
Enter fullscreen mode Exit fullscreen mode

Run node inspect-fixtures.mjs. The assertions passed, with these observations:

File Alpha metadata ffprobe pixel format Default sampled alpha Status
cutout.webm true yuv420p Values include 0 and 255 transparency-detected
tagged-opaque.webm true yuv420p All 255 no-transparency-observed
empty.webm true yuv420p All 0 fully-transparent-samples
brief-alpha.webm true yuv420p All 255 at requested samples no-transparency-observed

Here, all four files have the same metadata hint and reported pixel format. Their decoded content leads to three different statuses.

That is why a metadata-only gate cannot make the decision. This library explicitly selects the libvpx decoder for VP8/VP9 to preserve the WebM alpha data, then measures the decoded alpha plane. FFmpeg's alphaextract filter documentation describes the underlying extraction operation.

This experiment establishes the behavior of these fixtures and this decode path. It does not establish transparency support in every browser or video player.

Four-second fixture timeline showing default requests at 0, 1, 2, and 3 seconds missing the 1.2-to-1.7-second alpha interval.

The red requests miss the green interval. A focused 35% request reveals the partial alpha; requested times are not exact decoded timestamps.

The control that the default sampling misses

The fourth fixture is four seconds long. Default sampling requests frames around 0, 1, 2, and 3 seconds. Its partial transparency occurs between two of those requests.

The additional sampleFractions: [0.35] call asks for a frame around 1.4 seconds. In this run, it returned:

{
  "requestedTimeSeconds": 1.4,
  "status": "transparency-detected",
  "alphaMin": 129,
  "alphaMax": 129
}
Enter fullscreen mode Exit fullscreen mode

The measured alpha is near half opacity. Its exact 8-bit value should not be generalized to every encoding path.

More importantly, the default negative result did not mean “this clip contains no transparent frames.” It meant no qualifying transparency was observed in the requested samples.

The requested seek time is not a measured frame timestamp. FFmpeg may return the next available frame. Downscaling can also remove small features from the observations.

If a release requires whole-clip validation, design a bounded full-frame scan with an explicit alpha policy, or inspect all relevant frames in a trusted processing stage. Increasing a sparse sample count still does not turn sampling into a whole-clip guarantee.

Animated sequence of decoded frames showing the brief alpha change over light and dark backgrounds.

A slowed sequence of actual FFmpeg seek observations, not a real-time player recording. Frame requests and decoded alpha ranges are displayed.

Turn the observation into a CI gate

After building, the CLI can require the library's visible-cutout condition:

node dist/cli.js alpha-fixtures/cutout.webm --json --require-transparency
Enter fullscreen mode Exit fullscreen mode

The tested exit-code contract is:

Input Exit code Interpretation
cutout.webm 0 The sampled condition passed
tagged-opaque.webm 1 Inspection completed; condition was unmet
empty.webm 1 Inspection completed; no visible cutout was established
missing.webm 2 Inspection failed

In a CI step that already has the repository and required tools, use the command directly so its nonzero exit can fail the step. Preserve the JSON report as an artifact using the CI system's artifact mechanism.

Do not append || true, swallow the exit status, or convert decoder errors into “opaque video.” A missing tool, missing sample, or damaged file is an inspection failure. The JSON report should support diagnosis, not replace the gate's exit status.

This gate is appropriate only if its sampling policy is enough for the pipeline. It does not judge hair quality, temporal shimmer, color fringes, or whether a target application interprets alpha correctly.

Verified CLI outcomes: exit 0 passes the sampled condition, exit 1 means unmet, and exit 2 means inspection failed.

Keeping these outcomes separate prevents a missing file or decoder failure from masquerading as a successful inspection.

Keep a player test in the delivery workflow

The browser playground is a separate way to inspect synthetic samples and local WebM files. It uses the browser's decoder and Canvas, so it may observe different behavior from FFmpeg. Local file contents are processed in browser memory.

For a web overlay, retain a check in the destination browser. For an editing handoff, composite the actual output in the destination editor. Passing a decoded-pixel gate answers one part of delivery; those checks answer the remaining parts.

The four fixtures provide a useful starting regression set: useful cutout, opaque false positive, invisible output, and an event between samples.

If you already validate media exports in CI, does the gate promise a sampled observation or a whole-clip property? That distinction determines which files it can safely accept.


Project context: this inspector is maintained alongside BG Remover Online. It neither removes backgrounds nor uploads files, and its production integration into that site is pending.

AI disclosure: this article was drafted with AI. The synthetic fixtures, pixel inspections, assertions, and four CLI exit-code cases were executed against the recorded commit on October 23, 2026.

Top comments (0)