R RenderComp
remotion workflow typescript video-production input-props

Client Video Revision Versioning in Remotion

By RenderComp Team Editorial policy

Remotion’s core guarantee is determinism: given the same input props, frame 0 always looks identical to frame 0 from last Tuesday’s render. That property, which feels like an implementation detail when you first encounter it, becomes the foundation of something practical in client work: a revision system where every draft is reproducible, diffable, and traceable to the exact set of data that produced it.

Traditional video workflows treat revisions as file-system artifacts. You end up with final_v3_client_APPROVED_use_this_one.mp4 in a shared folder, and the relationship between that file and the After Effects project three directories up is maintained entirely in someone’s memory. Remotion changes this calculus: a video is a pure function of its props, so tracking revisions means tracking props.

This article builds a complete revision-tracking system from three interlocking pieces: a TypeScript schema that encodes revision metadata directly in input props, a RevisionOverlay composition that watermarks draft renders automatically, and a render script that snapshots props to disk and increments the round counter before each render. The result is an audit trail where any historical draft can be reconstructed from a single JSON file.


Encoding Revision Metadata as Input Props

Remotion’s --props CLI flag accepts arbitrary JSON, and @remotion/zod-types provides schema validation at render time. The cleanest approach is to fold revision metadata into your existing props type rather than maintaining a sidecar file.

// src/revisions/schema.ts
import { z } from "zod";

export const RevisionMetaSchema = z.object({
  round: z.number().int().min(1),           // R1 = first draft, R2 = second, etc.
  clientId: z.string().min(1),              // opaque identifier — no PII in rendered frames
  projectSlug: z.string().regex(/^[a-z0-9-]+$/),
  renderedAt: z.string().datetime(),        // ISO 8601, set by the render script, never hardcoded
  approved: z.boolean().default(false),
  changeRequests: z.array(z.string()).optional(),
});

export type RevisionMeta = z.infer<typeof RevisionMetaSchema>;

The approved field is the critical toggle. When false, the composition renders with a visible watermark; when true, the overlay is absent and the output is production-ready. Separating these two states in the same schema turns client approval into a data change rather than a manual file-renaming step, producing a measurably different render whose difference is captured in a snapshot file.

A full composition props type merges the revision schema with whatever content your video carries:

// src/compositions/PromoVideo/schema.ts
import { z } from "zod";
import { RevisionMetaSchema } from "../../revisions/schema";

export const PromoVideoSchema = RevisionMetaSchema.extend({
  headline: z.string().max(80),
  cta: z.string().max(40),
  accentColor: z.string().regex(/^#[0-9a-f]{6}$/i),
  durationInSeconds: z.number().positive(),
});

export type PromoVideoProps = z.infer<typeof PromoVideoSchema>;

This structure lets Remotion’s <Composition> validate props at compile time via defaultProps and at render time via the CLI, failing hard on any schema mismatch before a single frame is computed.


The RevisionOverlay Component

The overlay serves two purposes: it signals to reviewers that the video is a draft, and it records which round they are looking at. A fixed burn-in prevents the watermark from being cropped out when clients take screenshots for sharing.

The fade-in uses spring to avoid a harsh flash when a reviewer scrubs to frame 0. A secondary interpolate pulse cycles every two seconds to keep the watermark legible against busy background motion.

// src/revisions/RevisionOverlay.tsx
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  spring,
  interpolate,
} from "remotion";
import type { RevisionMeta } from "./schema";

interface OverlayProps {
  meta: RevisionMeta;
}

export const RevisionOverlay: React.FC<OverlayProps> = ({ meta }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Fade in over the first 12 frames (0.4 s at 30 fps).
  // stiffness: 60, damping: 20 keeps the spring critically damped so
  // there is no overshoot — the watermark arrives without bouncing.
  const baseOpacity = spring({
    frame,
    fps,
    from: 0,
    to: 0.85,
    config: { stiffness: 60, damping: 20, mass: 1 },
    durationInFrames: 12,
  });

  // After the fade-in settles, `frame % (fps * 2)` resets the
  // interpolation domain every 2 s, producing a gentle dip-and-recover
  // that keeps the watermark visible without being distracting.
  const pulseOpacity = interpolate(
    frame % (fps * 2),
    [0, fps * 0.5, fps * 1.5, fps * 2],
    [baseOpacity, baseOpacity * 0.65, baseOpacity * 0.65, baseOpacity],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  const timestamp = new Date(meta.renderedAt).toLocaleDateString("en-US", {
    year: "numeric",
    month: "short",
    day: "numeric",
  });

  return (
    <AbsoluteFill style={{ pointerEvents: "none" }}>
      {/* Corner stamp — top-left keeps it clear of typical lower-third content */}
      <div
        style={{
          position: "absolute",
          top: 32,
          left: 32,
          background: "rgba(0,0,0,0.72)",
          color: "#ffffff",
          fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
          fontSize: 22,
          fontWeight: 700,
          padding: "8px 16px",
          borderRadius: 4,
          letterSpacing: "0.04em",
          opacity: pulseOpacity,
        }}
      >
        DRAFT · Round {meta.round} · {meta.clientId}
      </div>

      {/* Bottom timestamp bar */}
      <div
        style={{
          position: "absolute",
          bottom: 0,
          left: 0,
          right: 0,
          background: "rgba(220, 38, 38, 0.85)",
          color: "#ffffff",
          fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
          fontSize: 18,
          padding: "6px 24px",
          opacity: pulseOpacity,
          display: "flex",
          justifyContent: "space-between",
        }}
      >
        <span>CONFIDENTIAL — FOR REVIEW ONLY</span>
        <span>Rendered {timestamp}</span>
      </div>
    </AbsoluteFill>
  );
};

Inside the main composition, the overlay is conditionally mounted based on approved. When true, the component does not enter the render tree at all, so the output is bit-for-bit clean with no hidden layer artifacts.

// src/compositions/PromoVideo/PromoVideo.tsx
import React from "react";
import { AbsoluteFill, Sequence } from "remotion";
import { RevisionOverlay } from "../../revisions/RevisionOverlay";
import type { PromoVideoProps } from "./schema";

export const PromoVideo: React.FC<PromoVideoProps> = (props) => {
  const { headline, cta, accentColor, approved, ...revisionMeta } = props;

  return (
    <AbsoluteFill style={{ background: "#0f0f0f" }}>
      <Sequence from={0} durationInFrames={60}>
        <HeadlineCard text={headline} accentColor={accentColor} />
      </Sequence>
      <Sequence from={30}>
        <CtaBanner text={cta} />
      </Sequence>

      {!approved && (
        <RevisionOverlay meta={{ ...revisionMeta, approved }} />
      )}
    </AbsoluteFill>
  );
};

Snapshotting Props to Disk

Each revision round needs a durable record of the exact props used to produce it. A flat JSON file per round, named with the project slug and round number, gives you a human-readable audit trail that works with any version control system. The SHA-256 prefix in the filename makes duplicate detection trivial: if the same content props produce the same hash, the client has not actually changed anything despite requesting a re-render.

// src/revisions/snapshot.ts
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";

export function saveSnapshot(
  projectSlug: string,
  round: number,
  props: Record<string, unknown>
): string {
  const dir = path.join("revision-snapshots", projectSlug);
  fs.mkdirSync(dir, { recursive: true });

  const json = JSON.stringify(props, null, 2);
  // Short hash of the content — lets downstream tooling detect whether
  // two snapshots carry the same payload even if round numbers differ.
  const hash = crypto.createHash("sha256").update(json).digest("hex").slice(0, 12);

  const filename = `r${String(round).padStart(2, "0")}-${hash}.json`;
  const filepath = path.join(dir, filename);
  fs.writeFileSync(filepath, json, "utf-8");
  return filepath;
}

export function loadSnapshot(filepath: string): Record<string, unknown> {
  return JSON.parse(fs.readFileSync(filepath, "utf-8"));
}

Committing revision-snapshots/ to git means every round has a timestamped entry in the project history. If a client approves round 3 and then requests a minor change, the round 4 snapshot will carry a different hash, providing an unambiguous record of what changed and when.


The Render Script

Manual prop editing invites mistakes, particularly when renderedAt must be updated and round must be incremented atomically. The @remotion/renderer programmatic API sidesteps shelling out to the CLI, eliminating quoting issues and keeping the script fully typed throughout.

// scripts/render-revision.ts
import path from "node:path";
import fs from "node:fs";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { saveSnapshot, loadSnapshot } from "../src/revisions/snapshot";

const PROJECT_SLUG = process.env.PROJECT_SLUG ?? "promo-video";
const COMPOSITION_ID = process.env.COMPOSITION_ID ?? "PromoVideo";
const BASE_PROPS_PATH =
  process.env.BASE_PROPS ?? `configs/${PROJECT_SLUG}-base.json`;

async function main() {
  const baseProps = loadSnapshot(BASE_PROPS_PATH);

  const snapshotDir = path.join("revision-snapshots", PROJECT_SLUG);
  // Count existing round files to infer the next round number.
  // Treat this directory as append-only — deleting snapshots will throw
  // off the counter and produce duplicate round numbers.
  const existingRounds = fs.existsSync(snapshotDir)
    ? fs.readdirSync(snapshotDir).filter((f) => /^r\d{2}-/.test(f)).length
    : 0;

  const nextRound = existingRounds + 1;

  const props = {
    ...baseProps,
    round: nextRound,
    renderedAt: new Date().toISOString(),
    approved: false,
  };

  const snapshotPath = saveSnapshot(PROJECT_SLUG, nextRound, props);
  console.log(`Snapshot saved → ${snapshotPath}`);

  const bundleLocation = await bundle({
    entryPoint: path.resolve("src/index.ts"),
    webpackOverride: (config) => config,
  });

  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: COMPOSITION_ID,
    inputProps: props,
  });

  const outputPath = path.join(
    "renders",
    PROJECT_SLUG,
    `r${String(nextRound).padStart(2, "0")}-draft.mp4`
  );

  fs.mkdirSync(path.dirname(outputPath), { recursive: true });

  await renderMedia({
    composition,
    serveUrl: bundleLocation,
    codec: "h264",
    outputLocation: outputPath,
    inputProps: props,
  });

  console.log(`Draft render complete → ${outputPath}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

For the final approved delivery, set approved: true in the base props file and run the script once more. Because the overlay component is absent from the render tree, the snapshot hash will differ from every draft round — the distinction between “last draft” and “approved output” is unambiguous in the file listing.

Run it with:

PROJECT_SLUG=promo-video COMPOSITION_ID=PromoVideo npx tsx scripts/render-revision.ts

Diffing Between Rounds

When a client sends feedback for round 3, knowing exactly which fields changed relative to round 2 before touching any code prevents re-implementing things the client did not request. A prop diff across two snapshot files surfaces the delta immediately.

The key implementation detail is flattening nested objects into dotted-key paths before comparing. Without flattening, a change to colors.accent would require deep equality checks across nested structures, and the comparison becomes unwieldy as the schema grows. With it, the diff is a flat list of changed paths.

// scripts/diff-rounds.ts
import { loadSnapshot } from "../src/revisions/snapshot";

function flatten(
  obj: Record<string, unknown>,
  prefix = ""
): Record<string, unknown> {
  return Object.entries(obj).reduce((acc, [key, value]) => {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    if (
      value !== null &&
      typeof value === "object" &&
      !Array.isArray(value)
    ) {
      Object.assign(acc, flatten(value as Record<string, unknown>, fullKey));
    } else {
      acc[fullKey] = value;
    }
    return acc;
  }, {} as Record<string, unknown>);
}

const [, , pathA, pathB] = process.argv;
if (!pathA || !pathB) {
  console.error("Usage: tsx scripts/diff-rounds.ts <snapshot-a> <snapshot-b>");
  process.exit(1);
}

const a = flatten(loadSnapshot(pathA));
const b = flatten(loadSnapshot(pathB));
const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);

let hasChanges = false;
for (const key of allKeys) {
  if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
    console.log(`  ${key}:`);
    console.log(`    − ${JSON.stringify(a[key])}`);
    console.log(`    + ${JSON.stringify(b[key])}`);
    hasChanges = true;
  }
}

if (!hasChanges) console.log("No content changes between snapshots.");

Fields like renderedAt and round always differ between rounds. What matters is whether content fields like headline, accentColor, or durationInSeconds changed. When the diff shows only renderedAt and round changing, the client has approved the content and needs only the watermark removed. That is a one-line data change rather than a content revision.


Wrapping Up

The system here depends entirely on Remotion’s determinism. Because a given set of props always produces the same frames, a snapshot file is not just documentation — it is a full reconstruction kit. Anyone with the codebase can reproduce any historical draft by passing the saved JSON to renderMedia, and that reproducibility is what separates this approach from a folder of numbered MP4 files.

The practical surface area is small: a Zod schema extending your existing props type, a watermark component gated on approved: boolean, a snapshot helper writing dated JSON files, and a render script tying them together. The revision-snapshots/ directory slots naturally into git, giving you a version-controlled history of every client feedback round without external tooling.

A few edge cases worth accounting for as you adapt this pattern. The round counter inferred from the snapshot directory count breaks if any file is deleted, so treat the directory as append-only. The renderedAt timestamp should always originate from the render script, never from a base config file that persists between runs. And clientId should be an opaque identifier like a short code rather than a full name, since it is burned into every draft frame and will appear in any screenshot the client shares. The pattern works especially well when layered onto structured video templates of the kind found in the RenderComp catalog, where the base content props are already typed and validated and the revision layer is a thin schema extension rather than a wholesale redesign.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →