{
  "name": "clip-trimmer",
  "type": "registry:component",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Clip Trimmer\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Pause, Play } from \"lucide-react\";\nimport Image from \"next/image\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst FRAMES = 10;\n// Handles pull to whole seconds within this many px.\nconst SNAP_PX = 5;\n\ntype Part = \"start\" | \"end\" | \"range\" | \"seek\";\n\nexport interface ClipRange {\n  start: number;\n  end: number;\n}\n\nexport interface ClipTrimmerProps {\n  /**\n   * A wide image the demo pans across to stand in for footage. Swap the\n   * preview and filmstrip for your own video and frame grabs.\n   */\n  src?: string;\n  alt?: string;\n  /** Clip length in seconds. */\n  duration?: number;\n  defaultRange?: ClipRange;\n  /** The shortest clip you can trim to, in seconds. */\n  minLength?: number;\n  onRangeChange?: (range: ClipRange) => void;\n  className?: string;\n}\n\nconst PANORAMA =\n  \"https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=2400&h=800&fit=crop&auto=format&q=80\";\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\nconst formatClock = (s: number) => {\n  const tenths = Math.round(s * 10);\n  const whole = Math.floor(tenths / 10);\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}.${tenths % 10}`;\n};\n\nexport default function ClipTrimmer({\n  src = PANORAMA,\n  alt = \"A valley road at dusk\",\n  duration = 12,\n  defaultRange = { start: 2.4, end: 9 },\n  minLength = 1,\n  onRangeChange,\n  className,\n}: ClipTrimmerProps) {\n  const [range, setRange] = useState<ClipRange>(defaultRange);\n  const [time, setTime] = useState(defaultRange.start);\n  const [playing, setPlaying] = useState(false);\n  const [active, setActive] = useState<Part | null>(null);\n  const [snapped, setSnapped] = useState<number | null>(null);\n  const stripRef = useRef<HTMLDivElement>(null);\n  const rangeRef = useRef(range);\n  const timeRef = useRef(time);\n  const drag = useRef<{\n    part: Part;\n    x: number;\n    from: ClipRange;\n    moved: boolean;\n  } | null>(null);\n\n  rangeRef.current = range;\n\n  useEffect(() => {\n    onRangeChange?.(range);\n  }, [range, onRangeChange]);\n\n  // Playback loops inside the kept range.\n  useEffect(() => {\n    if (!playing) {\n      return;\n    }\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const { start, end } = rangeRef.current;\n      let next = timeRef.current + (now - last) / 1000;\n      last = now;\n      if (next >= end || next < start) {\n        next = start;\n      }\n      timeRef.current = next;\n      setTime(next);\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing]);\n\n  const seek = (t: number) => {\n    const next = clamp(t, rangeRef.current.start, rangeRef.current.end);\n    timeRef.current = next;\n    setTime(next);\n  };\n\n  const pxPerSecond = () =>\n    (stripRef.current?.getBoundingClientRect().width ?? 1) / duration;\n\n  const secondsAt = (clientX: number) => {\n    const rect = stripRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return 0;\n    }\n    return clamp(((clientX - rect.left) / rect.width) * duration, 0, duration);\n  };\n\n  // A handle near a whole second pulls onto it, like a magnet.\n  const snap = (t: number) => {\n    const whole = Math.round(t);\n    if (Math.abs(whole - t) * pxPerSecond() < SNAP_PX) {\n      setSnapped(whole);\n      return whole;\n    }\n    setSnapped(null);\n    return t;\n  };\n\n  const moveHandle = (part: \"start\" | \"end\", t: number) => {\n    const { start, end } = rangeRef.current;\n    const next =\n      part === \"start\"\n        ? { start: clamp(snap(t), 0, end - minLength), end }\n        : { start, end: clamp(snap(t), start + minLength, duration) };\n    rangeRef.current = next;\n    setRange(next);\n    // The preview shows the frame you're cutting at.\n    const at = part === \"start\" ? next.start : next.end;\n    timeRef.current = at;\n    setTime(at);\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!event.isPrimary || event.button !== 0 || drag.current) {\n      return;\n    }\n    const target = event.target as HTMLElement;\n    const part = (target.closest(\"[data-part]\")?.getAttribute(\"data-part\") ??\n      \"seek\") as Part;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { part, x: event.clientX, from: range, moved: false };\n    setActive(part);\n    if (part === \"start\" || part === \"end\") {\n      setPlaying(false);\n    } else if (part === \"seek\") {\n      seek(secondsAt(event.clientX));\n    }\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) {\n      return;\n    }\n    const dx = event.clientX - d.x;\n    if (Math.abs(dx) > 3) {\n      d.moved = true;\n    }\n    if (d.part === \"start\" || d.part === \"end\") {\n      moveHandle(d.part, d.from[d.part] + dx / pxPerSecond());\n    } else if (d.part === \"range\" && d.moved) {\n      const length = d.from.end - d.from.start;\n      const start = clamp(\n        d.from.start + dx / pxPerSecond(),\n        0,\n        duration - length\n      );\n      const next = { start, end: start + length };\n      rangeRef.current = next;\n      setRange(next);\n      seek(timeRef.current);\n    } else if (d.part === \"seek\") {\n      seek(secondsAt(event.clientX));\n    }\n  };\n\n  const endDrag = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) {\n      return;\n    }\n    // A tap inside the kept range moves the playhead.\n    if (d.part === \"range\" && !d.moved) {\n      seek(secondsAt(event.clientX));\n    }\n    if (d.part === \"start\" || d.part === \"end\") {\n      seek(rangeRef.current.start);\n    }\n    drag.current = null;\n    setActive(null);\n    setSnapped(null);\n  };\n\n  const handleKeyDown =\n    (part: \"start\" | \"end\") => (event: KeyboardEvent<HTMLDivElement>) => {\n      const step = event.shiftKey ? 1 : 0.1;\n      const moves: Record<string, number> = {\n        ArrowLeft: -step,\n        ArrowDown: -step,\n        ArrowRight: step,\n        ArrowUp: step,\n        Home: -duration,\n        End: duration,\n      };\n      const move = moves[event.key];\n      if (move === undefined) {\n        return;\n      }\n      event.preventDefault();\n      const { start, end } = rangeRef.current;\n      const from = part === \"start\" ? start : end;\n      const t = Math.round((from + move) * 10) / 10;\n      const next =\n        part === \"start\"\n          ? { start: clamp(t, 0, end - minLength), end }\n          : { start, end: clamp(t, start + minLength, duration) };\n      rangeRef.current = next;\n      setRange(next);\n      seek(part === \"start\" ? next.start : next.end);\n    };\n\n  const togglePlay = () => {\n    if (!playing && timeRef.current >= range.end - 0.05) {\n      seek(range.start);\n    }\n    setPlaying((p) => !p);\n  };\n\n  const pct = (t: number) => (t / duration) * 100;\n  const kept = range.end - range.start;\n  const trimming = active === \"start\" || active === \"end\";\n  const ticks = Array.from({ length: Math.floor(duration) + 1 }, (_, i) => i);\n  // A longer, labelled tick every five seconds, like a tape ruler.\n  const labelEvery = 5;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-lg select-none rounded-3xl border border-zinc-200 bg-white p-4 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n    >\n      <div className=\"relative aspect-video overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-900\">\n        <Image\n          alt={alt}\n          className=\"pointer-events-none object-cover\"\n          draggable={false}\n          fill\n          sizes=\"(max-width: 512px) 100vw, 512px\"\n          src={src}\n          style={{ objectPosition: `${pct(time)}% 50%` }}\n          unoptimized\n        />\n        <span className=\"absolute bottom-3 left-3 rounded-full bg-black/55 px-2.5 py-1 font-medium text-[11px] text-white tabular-nums\">\n          {formatClock(time)}\n        </span>\n        <button\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          className=\"absolute right-3 bottom-3 flex size-8 items-center justify-center rounded-full bg-black/55 text-white outline-none transition-colors duration-150 ease-out hover:bg-black/70 focus-visible:ring-2 focus-visible:ring-white\"\n          onClick={togglePlay}\n          type=\"button\"\n        >\n          {playing ? (\n            <Pause aria-hidden className=\"size-3.5\" fill=\"currentColor\" />\n          ) : (\n            <Play\n              aria-hidden\n              className=\"size-3.5 translate-x-px\"\n              fill=\"currentColor\"\n            />\n          )}\n        </button>\n      </div>\n\n      <div className=\"mt-5 px-4\">\n        <div\n          className=\"relative h-14 cursor-pointer touch-none\"\n          onLostPointerCapture={endDrag}\n          onPointerCancel={endDrag}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={endDrag}\n          ref={stripRef}\n        >\n          {/* Filmstrip: one wide shot, cut into frames. */}\n          <div className=\"absolute inset-0 flex overflow-hidden rounded-lg bg-zinc-200 dark:bg-zinc-800\">\n            {Array.from({ length: FRAMES }, (_, i) => (\n              <div\n                className=\"relative h-full flex-1 border-black/10 border-r last:border-r-0\"\n                // biome-ignore lint/suspicious/noArrayIndexKey: fixed frame slots\n                key={i}\n              >\n                <Image\n                  alt=\"\"\n                  className=\"pointer-events-none object-cover\"\n                  draggable={false}\n                  fill\n                  sizes=\"64px\"\n                  src={src}\n                  style={{\n                    objectPosition: `${(i / (FRAMES - 1)) * 100}% 50%`,\n                  }}\n                  unoptimized\n                />\n              </div>\n            ))}\n          </div>\n\n          {/* What gets cut is dimmed. Full-width layers moved by transform. */}\n          <div className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-lg\">\n            <div\n              className=\"absolute inset-0 bg-white/75 dark:bg-zinc-950/75\"\n              style={{ transform: `translateX(${pct(range.start) - 100}%)` }}\n            />\n            <div\n              className=\"absolute inset-0 bg-white/75 dark:bg-zinc-950/75\"\n              style={{ transform: `translateX(${pct(range.end)}%)` }}\n            />\n          </div>\n\n          {/* Playhead */}\n          <div\n            className={cn(\n              \"pointer-events-none absolute -inset-y-1 left-0 w-full transition-opacity duration-150 ease-out\",\n              trimming ? \"opacity-0\" : \"opacity-100\"\n            )}\n            style={{ transform: `translateX(${pct(time)}%)` }}\n          >\n            <div className=\"relative h-full w-px -translate-x-1/2 bg-zinc-900 dark:bg-white\">\n              <span className=\"absolute -top-0.5 left-1/2 size-[7px] -translate-x-1/2 rounded-full bg-zinc-900 dark:bg-white\" />\n            </div>\n          </div>\n\n          {/* Kept range: the frame, its two handles and the drag area. */}\n          <div\n            className={cn(\n              \"absolute inset-y-0 border-orange-600 border-y-[3px]\",\n              active === \"range\" ? \"cursor-grabbing\" : \"cursor-grab\"\n            )}\n            data-part=\"range\"\n            style={{\n              left: `${pct(range.start)}%`,\n              width: `${pct(kept)}%`,\n            }}\n          >\n            {([\"start\", \"end\"] as const).map((part) => (\n              <div\n                aria-label={part === \"start\" ? \"Trim start\" : \"Trim end\"}\n                aria-valuemax={duration}\n                aria-valuemin={0}\n                aria-valuenow={Math.round(range[part] * 10) / 10}\n                aria-valuetext={`${range[part].toFixed(1)} seconds`}\n                className={cn(\n                  \"group absolute -inset-y-[3px] flex w-4 cursor-ew-resize items-center justify-center bg-orange-600 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n                  part === \"start\"\n                    ? \"-left-4 rounded-l-lg\"\n                    : \"-right-4 rounded-r-lg\"\n                )}\n                data-part={part}\n                key={part}\n                onKeyDown={handleKeyDown(part)}\n                role=\"slider\"\n                tabIndex={0}\n              >\n                <span\n                  className={cn(\n                    \"h-5 w-[3px] rounded-full bg-white/60 transition-colors duration-150 ease-out\",\n                    active === part && \"bg-white\"\n                  )}\n                />\n                {/* The exact cut point, while trimming. */}\n                <span\n                  className={cn(\n                    \"pointer-events-none absolute -top-8 rounded-md bg-zinc-900 px-1.5 py-0.5 text-[11px] text-white tabular-nums transition-opacity duration-150 ease-out dark:bg-zinc-100 dark:text-zinc-900\",\n                    active === part ? \"opacity-100\" : \"opacity-0\"\n                  )}\n                >\n                  {formatClock(range[part])}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        {/* Ruler: whole seconds, the one a handle snapped to lights up. */}\n        <div aria-hidden className=\"relative mt-2 h-5\">\n          {ticks.map((s) => (\n            <span\n              className=\"absolute top-0 flex -translate-x-1/2 flex-col items-center\"\n              key={s}\n              style={{ left: `${pct(s)}%` }}\n            >\n              <span\n                className={cn(\n                  \"w-px transition-colors duration-150 ease-out\",\n                  s % labelEvery === 0 ? \"h-2\" : \"h-1\",\n                  snapped === s\n                    ? \"bg-orange-600\"\n                    : \"bg-zinc-300 dark:bg-zinc-700\"\n                )}\n              />\n              {s % labelEvery === 0 && (\n                <span\n                  className={cn(\n                    \"mt-0.5 text-[10px] tabular-nums transition-colors duration-150 ease-out\",\n                    snapped === s\n                      ? \"text-orange-700 dark:text-orange-400\"\n                      : \"text-zinc-400 dark:text-zinc-500\"\n                  )}\n                >\n                  {s}s\n                </span>\n              )}\n            </span>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"mt-3 flex items-center justify-between gap-3 px-1 text-sm\">\n        <p className=\"text-zinc-500 tabular-nums dark:text-zinc-400\">\n          <span className=\"font-medium text-zinc-900 dark:text-zinc-100\">\n            {kept.toFixed(1)}s\n          </span>{\" \"}\n          of {duration.toFixed(1)}s\n        </p>\n        <button\n          className=\"rounded-full px-3 py-1 text-xs text-zinc-500 outline-none transition-colors duration-150 ease-out hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n          disabled={range.start === 0 && range.end === duration}\n          onClick={() => {\n            const full = { start: 0, end: duration };\n            rangeRef.current = full;\n            setRange(full);\n            seek(timeRef.current);\n          }}\n          type=\"button\"\n        >\n          Reset\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/clip-trimmer.tsx",
      "target": "components/kokonutui/clip-trimmer.tsx"
    }
  ]
}