Security Cam

Run a scene through a cheap recorder — macroblocks, collapsed colour, crushed blacks, crawling noise

Experimental — html-in-canvas

This filter re-reads the real pixels of the scene it wraps through the experimental html-in-canvas browser API. The preview above shows the true effect in Chrome 149+ with chrome://flags/#canvas-draw-element enabled (nested canvases need 152.0.7944.0+). Every other browser — Safari, Firefox, Chrome without the flag — falls back to a CSS approximation, so the page and your video still play.

Rendering is supported through the CLI, Studio, Lambda and SSR with --gl=angle, or --gl=swangle on a machine without a GPU. Chrome may change or discontinue the API; the fallback is what keeps your composition renderable if that happens.

Installation

$ pnpm dlx shadcn@latest add @remocn/security-cam

Usage

SecurityCam is a wrapper. Put a scene inside it and it comes back as something that went through a cheap recorder: the picture flattens into macroblocks where the bitrate could not keep up, colour resolution collapses to half the detail of brightness, blacks crush, the exposure hunts slightly, and noise crawls in the shadows.

import { SecurityCam } from "@/components/remocn/security-cam";
 
export const MyScene = () => (
  <SecurityCam>
    <Scene />
  </SecurityCam>
);

Why there is no fisheye here

The obvious build is a wide bow plus desaturation, and it is a trap: bending the frame is what crt-screen does, and a filter whose identity is a warp ends up reading as a curved display with the scanlines missing. camera-lens already covers optics, and it has a distortion prop if you want a wide-angle bow — nest the two and you get a wide lens feeding a cheap recorder.

What makes footage look like surveillance is not the lens, it is the codec. So that is what this is: macroblocking and chroma subsampling, the two artifacts every starved video stream has and no display ever produces.

What to reach for it for

Open cold, then cut to the truth

The strongest use. The scene plays as a recording, and on a hard cut the surveillance drops away and the real interface is underneath — the same shot, suddenly clean.

import { interpolate, useCurrentFrame } from "remotion";
import { SecurityCam } from "@/components/remocn/security-cam";
 
export const Opening = () => {
  const frame = useCurrentFrame();
  const intensity = interpolate(frame, [0, 40, 44], [1, 1, 0], {
    extrapolateRight: "clamp",
  });
 
  return (
    <SecurityCam intensity={intensity}>
      <Scene />
    </SecurityCam>
  );
};

Cut, do not fade. A recording that dissolves into a clean render is a crossfade; one that switches in a single frame is an edit.

Give it an OSD

The wrapper adds nothing of its own — no timestamp, no camera label, no recording dot. Those belong to your scene, and putting them inside the wrapper is what sells it: burned-in overlays get compressed along with everything else, exactly as a real recorder writes them.

<SecurityCam>
  <AbsoluteFill>
    <Scene />
    <CameraOsd />
  </AbsoluteFill>
</SecurityCam>

Starve the bitrate

Drive compression and blockSize together and the feed collapses: detail dissolves into flat squares, colour smears across blocks twice their size, and the picture becomes something you can only just read. Good for a moment of things going wrong.

const starve = interpolate(frame, [0, 36], [0.15, 1], {
  extrapolateRight: "clamp",
});
const block = interpolate(frame, [0, 36], [6, 26], {
  extrapolateRight: "clamp",
});

Props

PropTypeDefaultDescription
compression
number0.7How hard the codec is squeezing. Pulls each block toward its own average, coarsens the brightness quantizer, and collapses colour onto blocks twice the size. At 0 the picture keeps its detail and only the tone and noise remain.
blockSize
number10Macroblock edge in composition units. Colour is always sampled on blocks twice this size, which is what makes the result read as video rather than as a mosaic.
noise
number0.6Sensor grain, weighted toward the shadows the way a small sensor at high gain behaves. Bright areas stay comparatively clean.
intensity
number1Master amount. Read every frame, so cutting it to 0 hands the clean scene back in a single frame. At 0 the scene passes through exactly as it was.
children
React.ReactNodeThe scene the filter wraps and re-reads every frame.

Notes

Colour is sampled on blocks twice the size of the brightness blocks, which is the detail doing most of the work. Real video codecs throw away colour resolution first because the eye barely notices — until it does, and a red badge bleeds across a square it does not belong to. That mismatch between sharp brightness and blocky colour is what the eye reads as "compressed", and it is a genuinely different artifact from the sideways chroma smear of vhs-filter.

The only motion is a slow exposure pump on a 90-frame cycle and the shadow noise, which is reseeded per frame from a 64-frame clock. Both are pure functions of the frame number, so parallel renders match and nothing drifts.

In browsers without html-in-canvas the scene keeps the wash and the vignette but not the blocking. Quantizing a frame into macroblocks is not something a stylesheet can do, and a plain blur pretending to be compression would be a different artifact wearing this one's name.