<3/products/personal-site/components/

VideoInspector.tsx

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Player, type PlayerRef } from "@remotion/player";
import { getSceneComponent } from "@/remotion/registry";
import type { SceneMeta } from "@/remotion/scenes.meta";
import { THEME } from "@/remotion/theme";
import styles from "./VideoInspector.module.css";

/**
 * The craft surface for a scene: a Player with full playback + single-frame
 * stepping + a scrubber + a frame counter, with MP4 and banner exports.
 * Used at /videos/<id>. This is where you confirm every frame is right and that
 * frame 0 lines up with the final frame for a seamless loop.
 *
 * The video is exported on a SOLID background (X renders transparency as black),
 * while the preview here shows that same solid background so what you inspect is
 * what you export.
 */
export function VideoInspector({ meta }: { meta: SceneMeta }) {
  const component = getSceneComponent(meta.id);
  const playerRef = useRef<PlayerRef>(null);

  const [frame, setFrame] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [exporting, setExporting] = useState<"mp4" | "banner" | null>(null);
  const [error, setError] = useState<string | null>(null);

  const last = meta.durationInFrames - 1;
  // Solid background so the inspector matches the exported video exactly. Same
  // near-white surface as the essay viz panels (--viz-bg), via the shared token.
  const exportBg = THEME.vizBg;
  const inputProps = { ...meta.defaultProps, background: exportBg };

  // Mirror the player's frame + play state into React so the UI can display them.
  useEffect(() => {
    const player = playerRef.current;
    if (!player) return;
    const onFrame = (e: { detail: { frame: number } }) => setFrame(e.detail.frame);
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    player.addEventListener("frameupdate", onFrame);
    player.addEventListener("play", onPlay);
    player.addEventListener("pause", onPause);
    return () => {
      player.removeEventListener("frameupdate", onFrame);
      player.removeEventListener("play", onPlay);
      player.removeEventListener("pause", onPause);
    };
  }, []);

  const seek = useCallback((f: number) => {
    const clamped = Math.max(0, Math.min(last, f));
    playerRef.current?.pause();
    playerRef.current?.seekTo(clamped);
  }, [last]);

  const stepBy = useCallback((delta: number) => seek(frame + delta), [frame, seek]);

  // Arrow keys step frames; space toggles playback — the expected craft shortcuts.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "ArrowRight") { e.preventDefault(); stepBy(e.shiftKey ? 10 : 1); }
      else if (e.key === "ArrowLeft") { e.preventDefault(); stepBy(e.shiftKey ? -10 : -1); }
      else if (e.key === " ") { e.preventDefault(); playerRef.current?.toggle(); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [stepBy]);

  const onExport = useCallback(async (format: "mp4" | "banner") => {
    setExporting(format);
    setError(null);
    try {
      const res = await fetch("/api/render", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: meta.id, inputProps, format }),
      });
      if (!res.ok) throw new Error(await res.text());
      const blob = await res.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = format === "banner" ? `${meta.id}-banner.png` : `${meta.id}.mp4`;
      a.click();
      URL.revokeObjectURL(url);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Export failed");
    } finally {
      setExporting(null);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [meta.id]);

  const seconds = (frame / meta.fps).toFixed(2);
  const totalSeconds = (meta.durationInFrames / meta.fps).toFixed(2);

  return (
    <div className={styles.wrap}>
      <div className={styles.stage}>
        <div className={`${styles.sceneBody} ${meta.loops ? styles.padded : ""}`}>
          <div
            className={styles.canvas}
            style={{
              maxWidth: meta.embedMaxWidth,
              aspectRatio: `${meta.width} / ${meta.height}`,
            }}
          >
            <Player
              ref={playerRef}
              component={component}
              durationInFrames={meta.durationInFrames}
              fps={meta.fps}
              compositionWidth={meta.width}
              compositionHeight={meta.height}
              inputProps={inputProps}
              acknowledgeRemotionLicense
              loop
              controls={false}
              clickToPlay={false}
              spaceKeyToPlayOrPause={false}
              style={{ width: "100%", height: "100%" }}
            />
          </div>
        </div>
      </div>

      {/* Scrubber */}
      <input
        type="range"
        min={0}
        max={last}
        value={frame}
        onChange={(e) => seek(Number(e.target.value))}
        className={styles.scrubber}
        aria-label="Timeline"
      />

      <div className={styles.controls}>
        <div className={styles.transport}>
          <button type="button" onClick={() => seek(0)} title="First frame">⏮</button>
          <button type="button" onClick={() => stepBy(-1)} title="Previous frame (←)">◀</button>
          <button type="button" onClick={() => playerRef.current?.toggle()} title="Play / pause (space)">
            {playing ? "❚❚" : "▶"}
          </button>
          <button type="button" onClick={() => stepBy(1)} title="Next frame (→)">▶</button>
          <button type="button" onClick={() => seek(last)} title="Last frame">⏭</button>
        </div>

        <div className={styles.counter}>
          <span className={styles.frameNum}>frame {frame} / {last}</span>
          <span className={styles.time}>{seconds}s / {totalSeconds}s · {meta.fps}fps · {meta.width}×{meta.height}</span>
        </div>

        <div className={styles.exportActions}>
          <button
            type="button"
            onClick={() => onExport("mp4")}
            disabled={exporting !== null}
            className={styles.export}
          >
            {exporting === "mp4" ? "Rendering…" : "Export MP4"}
          </button>
          <button
            type="button"
            onClick={() => onExport("banner")}
            disabled={exporting !== null}
            className={styles.export}
          >
            {exporting === "banner" ? "Rendering…" : "Export Banner"}
          </button>
        </div>
      </div>

      <p className={styles.hint}>
        ← / → step one frame (hold shift for 10). To check the loop, compare
        frame 0 with frame {last} — they should differ by exactly one step of motion.
      </p>

      {error && <pre className={styles.error}>{error}</pre>}
    </div>
  );
}