khanhanh@sydney: ~/projects/lapse · cat lapse.md
$ cd .. back to ~/projects (esc)
project: Lapse: browser recording that turns hours into seconds of proofrole: solo engineer: product, architecture and deploymentstack: Next.js 16 · TypeScript · Postgres · Drizzle · Bunny · Clerktimeline: public beta · 2026status: ● live at lapse.work · source on github

Lapse turns hours of work into seconds of proof.

Builders finish a long work session, then lose another hour trimming a recording nobody wants to download. Lapse records in the browser, accelerates the slow parts up to 16x and produces one link a client or teammate can watch immediately.

The product constraint

The browser is not only the interface. It is the capture device and the editing runtime. Lapse has to coordinate screen, camera and audio streams without a desktop installer, then move large media files to hosted playback without sending them through the application server.

  • Keep processing local. Time-lapse and styled exports run on the user's machine, avoiding a render queue.
  • Fail before memory becomes dangerous. Memory recording warns at 400 MiB and stops above 500 MiB. Supported browsers can stream chunks directly to a user-selected file instead.
  • Authorize the link, not only the page. Private playback receives a short-lived URL only after the current viewer is checked.

Decision 1: composite the camera off the main thread

Camera overlays are expensive when every video frame shares the UI thread. When the browser exposes the required media primitives, Lapse transfers both readable streams and the generated output stream to a worker.

const displayProcessor = new MediaStreamTrackProcessor({
  track: displayTrack,
});
const cameraProcessor = new MediaStreamTrackProcessor({
  track: cameraTrack.clone(),
});
const output = new MediaStreamTrackGenerator({ kind: "video" });

worker.postMessage(
  {
    type: "init",
    display: displayProcessor.readable,
    camera: cameraProcessor.readable,
    output: output.writable,
  },
  [displayProcessor.readable, cameraProcessor.readable, output.writable],
);
WHY IT MATTERS

Recording should not make the controls feel broken. Moving frame composition into a worker keeps interaction separate from media processing and leaves a simpler capture path for browsers without the required APIs.

Decision 2: upload media directly, finalize it transactionally

The browser uploads video to Bunny over TUS with retry delays of 0, 3, 5, 10 and 20 seconds. The application stores an expiring reservation first, then finalizes metadata only after Bunny reports the video and its thumbnail is present.

Finalization locks both the user and reservation rows. Inside one database transaction it rechecks duration, per-video size, total storage and private-video limits. A consumed reservation returns its existing video ID, making the operation safe to retry.

const [reservation] = await tx
  .select()
  .from(videoUploadReservations)
  .where(
    and(
      eq(videoUploadReservations.ownerId, input.ownerId),
      eq(videoUploadReservations.bunnyVideoId, input.bunnyVideoId),
    ),
  )
  .for("update");

if (reservation.videoId) return { id: reservation.videoId };

if (input.storageBytes > limits.maxUploadBytes) {
  throw new OversizeError(perVideoCapMessage(limits));
}

if (currentStorageBytes + input.storageBytes > limits.maxStorageBytes) {
  throw new OversizeError(storageFullMessage(limits));
}
BOUNDARY

The CDN moves bytes. Postgres decides whether those bytes become a video in the user's library. Keeping those responsibilities separate makes retries cheap without weakening plan enforcement.

Decision 3: issue playback URLs on intent

Video lists do not carry permanent playback URLs. A viewer presses play, the server reauthorizes access and only then returns a signed Bunny embed URL. Public videos, owners, invited users and verified guests all pass through the same read operation.

const record = await readVideo(videoId);
if (!record) throw new NotFoundError("Video not found");

return {
  success: true,
  data: {
    src: getEmbedPlaybackUrl(getVideoIdFromUrl(record.video.videoUrl)),
  },
};

What shipped

16x
maximum time-lapse speed
4K
maximum Pro capture
1 link
from recording to playback

The public beta includes browser capture, local styling, hosted playback, invite-only sharing and a public Explore feed. Watch alerts and library analytics close the loop after a recording is sent.

Tradeoffs I accepted

  • Advanced camera compositing depends on newer Chromium media APIs. Capability checks select a simpler path when they are unavailable.
  • Local export uses the user's CPU and takes real elapsed time. The design favors privacy and zero render infrastructure over instant cloud processing.
  • Live comments and notifications use Postgres events with SSE, which requires infrastructure that supports persistent connections.

The result is not another screen-capture button. It is a complete path from doing the work to proving it happened.

-- EOF · written by khanhanh, edited by nobody● open live ↗