Blocks
Ready-made business compositions built from the ikui primitives — previewed live, with the full source. Copy, paste, or install with the CLI.
Video Frame Extractor
A business composition over mediabunny's VideoSampleSink: load a video, then grab the still under the playhead at native resolution. A ruler aligned to the real duration sits above a continuous filmstrip carrying a playhead that tracks playback; the `<video>` has no native controls — play/pause lives outside it — so wherever it plays (or you drag the playhead) to, one button saves that exact frame. The zoom slider sets the timeline density for fine scrubbing. PNG or JPEG.
'use client'
import {
Download,
Loader2,
Maximize2,
Pause,
Play,
Upload,
ZoomIn,
ZoomOut,
} from 'lucide-react'
import * as React from 'react'
import { ThumbnailStrip } from '@/components/thumbnail-strip'
import { TimelinePlayhead } from '@/components/timeline-playhead'
import { TimelineRuler } from '@/components/timeline-ruler'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Slider } from '@/components/ui/slider'
import { VideoThumbnailCache } from '@/lib/video-thumbnail-cache'
const SAMPLE_VIDEO_URL =
'https://hj-video.zeroaigen.cn/prod/AI/VIDEO/f4e7fdc9807348eedc1e64a963c7433e.mp4'
type Format = 'png' | 'jpeg'
interface SourceMeta {
blob: Blob
url: string
cache: VideoThumbnailCache
duration: number
width: number
height: number
}
const FORMAT_LABEL: Record<Format, string> = { png: 'PNG', jpeg: 'JPEG' }
const STRIP_HEIGHT = 72
const RULER_HEIGHT = 24
const ZOOM_MIN = 0.5
const ZOOM_MAX = 4
// Matches TimelinePlayhead's knob diameter — the track is padded by half this
// on each side so the knob stays fully visible at either end.
const PLAYHEAD_KNOB = 12
/** `mm:ss.s` timestamp, e.g. `00:15.5`. */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`
}
/** Linear slider position (0–1) → exponential zoom, so low values step gently. */
function sliderToZoom(position: number, min: number, max: number): number {
if (max <= min) return min
const p = Math.max(0, Math.min(1, position))
return min * (max / min) ** p
}
/** Inverse of `sliderToZoom`. */
function zoomToSlider(zoom: number, min: number, max: number): number {
if (max <= min) return 0
const z = Math.max(min, Math.min(max, zoom))
return Math.log(z / min) / Math.log(max / min)
}
export interface VideoFrameExtractorProps {
/** Video to load and extract from. Falls back to a bundled sample. */
videoUrl?: string
/** Output image format for downloaded stills. Default: `'png'`. */
format?: Format
/** Base pixels per second at zoom = 1 — sets the timeline scale. Default: `50`. */
pixelsPerSecond?: number
}
/**
* Video frame extractor — load a video (or use the sample) and grab the still
* **under the playhead**. A time ruler runs across the top aligned to the real
* duration; beneath it a continuous filmstrip (mediabunny-decoded thumbnails)
* carries a playhead that tracks playback. Play/pause lives outside the video —
* the `<video>` itself has no native controls — so wherever it plays (or you
* drag the playhead) to, one button saves that exact frame at native
* resolution. The zoom slider sets how dense the timeline is for fine scrubbing.
*/
export function VideoFrameExtractor({
videoUrl = SAMPLE_VIDEO_URL,
format: initialFormat = 'png',
pixelsPerSecond = 50,
}: VideoFrameExtractorProps) {
const [file, setFile] = React.useState<File | null>(null)
const [meta, setMeta] = React.useState<SourceMeta | null>(null)
const [format, setFormat] = React.useState<Format>(initialFormat)
const [zoom, setZoom] = React.useState(1)
const [containerWidth, setContainerWidth] = React.useState(0)
const [currentTime, setCurrentTime] = React.useState(0)
const [playing, setPlaying] = React.useState(false)
const [downloading, setDownloading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const videoRef = React.useRef<HTMLVideoElement>(null)
// Auto-fit the zoom once per source, after the width is known.
const didFitRef = React.useRef(false)
const resizeObserverRef = React.useRef<ResizeObserver | null>(null)
// Measure the available width (callback ref re-attaches when the node mounts).
const measureRef = React.useCallback((el: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect()
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth))
ro.observe(el)
resizeObserverRef.current = ro
}, [])
// Resolve the source to a Blob (uploaded File or fetched URL), build the
// thumbnail cache, and read duration + intrinsic size off its metadata. The
// same blob feeds native-resolution decoding on download. Switching sources
// disposes the previous cache, rewinds the playhead, and re-arms the auto-fit.
React.useEffect(() => {
let cancelled = false
let objectUrl: string | null = null
let cache: VideoThumbnailCache | null = null
setMeta(null)
setError(null)
setCurrentTime(0)
setPlaying(false)
didFitRef.current = false
void (async () => {
try {
const blob = file ?? (await (await fetch(videoUrl)).blob())
if (cancelled) return
cache = new VideoThumbnailCache({ source: blob, thumbnailHeight: 96 })
const { duration, width, height } = await cache.initialize()
if (cancelled) {
cache.dispose()
return
}
if (duration <= 0) throw new Error('no duration')
objectUrl = URL.createObjectURL(blob)
setMeta({ blob, url: objectUrl, cache, duration, width, height })
} catch {
cache?.dispose()
if (!cancelled) setError('Could not load the video.')
}
})()
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
cache?.dispose()
}
}, [file, videoUrl])
// Tile geometry derived from zoom; the filmstrip cells keep the source aspect.
const aspect =
meta && meta.width > 0 && meta.height > 0
? meta.width / meta.height
: 16 / 9
const tileWidth = Math.max(1, Math.round(STRIP_HEIGHT * aspect))
const pps = pixelsPerSecond * zoom
const total = meta?.duration ?? 0
const contentWidth = total * pps
// Zoom that fits the whole video in the available width. Subtract the track
// padding (half a knob each side) so the filled timeline lands exactly on the
// viewport edge instead of leaving a sliver of scrollable overflow.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(
ZOOM_MAX,
(containerWidth - PLAYHEAD_KNOB) / (total * pixelsPerSecond),
)
: 1
const minZoom = Math.min(ZOOM_MIN, fitZoom)
const maxZoom = Math.max(ZOOM_MAX, fitZoom)
const sliderPos = zoomToSlider(zoom, minZoom, maxZoom)
// Fit once, when the width and duration first become known.
React.useEffect(() => {
if (!total || containerWidth <= 0 || didFitRef.current) return
setZoom(fitZoom)
didFitRef.current = true
}, [total, containerWidth, fitZoom])
// While playing, drive the playhead off a rAF loop so it tracks smoothly
// (the `timeupdate` event only fires a few times a second).
React.useEffect(() => {
if (!playing) return
let raf = 0
const tick = () => {
const video = videoRef.current
if (video) setCurrentTime(video.currentTime)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [playing])
const applySlider = (next: number) => {
didFitRef.current = true
setZoom(sliderToZoom(next, minZoom, maxZoom))
}
const stepZoom = (delta: number) => applySlider(sliderPos + delta)
const fit = () => {
didFitRef.current = true
setZoom(fitZoom)
}
// Seek both the playhead and the underlying video (the only way to move the
// playhead, since the `<video>` exposes no native scrubber).
const seek = (time: number) => {
const clamped = Math.min(total, Math.max(0, time))
setCurrentTime(clamped)
const video = videoRef.current
if (video) video.currentTime = clamped
}
// Click the ruler / filmstrip to jump the playhead there. The inner track's
// left edge is time 0, so the pointer x maps straight to a time. (Dragging
// the playhead knob is handled by TimelinePlayhead itself.)
const scrubFrom = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
seek((event.clientX - rect.left) / pps)
}
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play()
else video.pause()
}
// Decode a single timestamp at native resolution and hand back the still. A
// fresh mediabunny input keeps full-res export off the (downscaled) preview
// cache.
const decodeFrame = React.useCallback(
async (blob: Blob, time: number): Promise<Blob | null> => {
const { Input, BlobSource, VideoSampleSink, ALL_FORMATS } = await import(
'mediabunny'
)
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
})
const track = await input.getPrimaryVideoTrack()
if (!track || !(await track.canDecode())) throw new Error('undecodable')
const sink = new VideoSampleSink(track)
const sample = await sink.getSample(time)
if (!sample) return null
try {
const canvas = new OffscreenCanvas(
sample.codedWidth,
sample.codedHeight,
)
const ctx = canvas.getContext('2d')
if (!ctx) return null
sample.draw(ctx, 0, 0, sample.codedWidth, sample.codedHeight)
const mime = format === 'png' ? 'image/png' : 'image/jpeg'
const quality = format === 'jpeg' ? 0.92 : undefined
return await canvas.convertToBlob({ type: mime, quality })
} finally {
sample.close()
}
},
[format],
)
const baseName = (file?.name ?? 'video').replace(/\.[^.]+$/, '')
const save = (blob: Blob, time: number) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${baseName}-${time.toFixed(1)}s.${format}`
a.click()
URL.revokeObjectURL(url)
}
// Grab the frame sitting under the playhead, at native resolution.
const downloadFrame = async () => {
if (!meta || downloading) return
setDownloading(true)
setError(null)
try {
const blob = await decodeFrame(meta.blob, currentTime)
if (blob) save(blob, currentTime)
} catch {
setError('Extraction failed — this codec may be unsupported here.')
} finally {
setDownloading(false)
}
}
if (!meta) {
if (error) {
return (
<Card className="w-full">
<CardContent className="flex flex-col items-center gap-4 pt-(--card-spacing)">
<p className="text-destructive text-sm">{error}</p>
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load video
<input
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
<Skeleton className="mx-auto aspect-video w-full max-w-md" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-28 w-full rounded-lg" />
</CardContent>
<CardFooter className="items-center justify-between gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-36" />
</CardFooter>
</Card>
)
}
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
<video
ref={videoRef}
src={meta.url}
playsInline
style={{ aspectRatio: aspect }}
className="bg-muted/30 mx-auto max-h-64 max-w-full rounded-lg"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onLoadedMetadata={(event) =>
setCurrentTime(event.currentTarget.currentTime)
}
/>
{/* Toolbar — transport on the left, zoom controls on the right. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<Button
type="button"
size="icon-lg"
className="rounded-full"
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause /> : <Play className="translate-x-px" />}
</Button>
<span className="text-muted-foreground text-xs tabular-nums">
<span className="text-foreground font-medium">
{formatTime(currentTime)}
</span>{' '}
/ {formatTime(total)}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Zoom out"
onClick={() => stepZoom(-0.1)}
>
<ZoomOut />
</Button>
<div className="w-28">
<Slider
min={0}
max={100}
value={[sliderPos * 100]}
onValueChange={(value) =>
applySlider((Array.isArray(value) ? value[0] : value) / 100)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
title="Zoom in"
onClick={() => stepZoom(0.1)}
>
<ZoomIn />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Fit to width"
onClick={fit}
>
<Maximize2 />
</Button>
<span className="text-muted-foreground w-9 text-right text-xs tabular-nums">
{Math.max(1, Math.round(sliderPos * 100))}%
</span>
</div>
</div>
{/* Timeline — ruler aligned to the real duration, a continuous filmstrip
beneath it, and a playhead that tracks playback. Click anywhere to
jump, or drag the playhead to scrub. */}
<div className="bg-muted/30 rounded-lg p-3">
<div ref={measureRef}>
<ScrollArea
style={{
height: RULER_HEIGHT + 8 + STRIP_HEIGHT + PLAYHEAD_KNOB + 8,
}}
>
{/* Pad the scroll content by half a knob on every side so the
playhead circle stays fully visible at the start, end and top
instead of being clipped by the viewport. The inner track is
the positioning origin shared by the ruler, strip and
playhead, so they all stay aligned. */}
<div
style={{
width: contentWidth + PLAYHEAD_KNOB,
minWidth: '100%',
padding: PLAYHEAD_KNOB / 2,
boxSizing: 'border-box',
}}
>
<div
style={{
position: 'relative',
width: contentWidth,
minWidth: '100%',
cursor: 'pointer',
}}
onPointerDown={scrubFrom}
>
<TimelineRuler
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={RULER_HEIGHT}
/>
<div
className="bg-background/40 mt-2 overflow-hidden rounded-md border"
style={{
position: 'relative',
width: contentWidth,
height: STRIP_HEIGHT,
}}
>
<ThumbnailStrip
cache={meta.cache}
duration={total}
totalWidth={contentWidth}
tileWidth={tileWidth}
tileHeight={STRIP_HEIGHT}
/>
</div>
<TimelinePlayhead
currentTime={currentTime}
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
color="var(--color-primary)"
onSeek={seek}
/>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
</CardContent>
{/* Footer — output format on the left, source / download on the right. */}
<CardFooter className="items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Format</span>
<Select
value={format}
disabled={downloading}
onValueChange={(value) => setFormat(value as Format)}
>
<SelectTrigger>
<SelectValue>
{(value: Format) => FORMAT_LABEL[value]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(FORMAT_LABEL) as Format[]).map((f) => (
<SelectItem key={f} value={f}>
{FORMAT_LABEL[f]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="ml-auto flex items-center gap-2">
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load video
<input
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
<Button type="button" disabled={downloading} onClick={downloadFrame}>
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
Download frame
</Button>
</div>
</CardFooter>
</Card>
)
}
export default VideoFrameExtractor
Video Trimmer
A business composition built from the timeline primitives: trim a video clip with the timeline-element handles over a thumbnail strip, then play back only the trimmed [startTime, startTime + duration] window in the preview. The playhead follows playback, the frames outside the selection dim, and an In / Out / length readout tracks the selection. Export cuts the window to an MP4 via mediabunny.
'use client'
import {
Download,
Loader2,
Maximize2,
Pause,
Play,
Upload,
ZoomIn,
ZoomOut,
} from 'lucide-react'
import * as React from 'react'
import { ThumbnailStrip } from '@/components/thumbnail-strip'
import type { TimelineElementResize } from '@/components/timeline-element'
import { TimelineElement } from '@/components/timeline-element'
import { TimelinePlayhead } from '@/components/timeline-playhead'
import { TimelineRuler } from '@/components/timeline-ruler'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Slider } from '@/components/ui/slider'
import { VideoThumbnailCache } from '@/lib/video-thumbnail-cache'
const SAMPLE_VIDEO_URL =
'https://hj-video.zeroaigen.cn/prod/AI/VIDEO/f4e7fdc9807348eedc1e64a963c7433e.mp4'
const ZOOM_MAX = 10
// Floor for the slider so you can always zoom out past fit-to-screen and give
// the trim handles breathing room. Lowered to `fitZoom` when a clip is so long
// it only fits below this — so "Fit" stays reachable. (bycut uses a fixed range.)
const ZOOM_MIN = 0.5
const RULER_HEIGHT = 24
// Matches TimelinePlayhead's knob diameter — the track is padded by half this
// on each side so the knob stays fully visible at either end.
const PLAYHEAD_KNOB = 12
export interface VideoTrimmerProps {
/** Video to load, visualize and trim. Falls back to a bundled sample. */
videoUrl?: string
/** Base pixels per second at zoom = 1. Default: `50`. */
pixelsPerSecond?: number
/** Thumbnail track height in CSS px. Default: `64`. */
height?: number
/** Fired with the trimmed selection while dragging the handles. */
onChange?: (selection: TimelineElementResize) => void
/** Fired with the exported MP4 blob after a trim. */
onExport?: (blob: Blob) => void
}
/** `mm:ss.s` timestamp, e.g. `00:15.5`. */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`
}
/** Linear slider position (0–1) → exponential zoom, so low values step gently. */
function sliderToZoom(position: number, min: number, max: number): number {
if (max <= min) return min
const p = Math.max(0, Math.min(1, position))
return min * (max / min) ** p
}
/** Inverse of `sliderToZoom`. */
function zoomToSlider(zoom: number, min: number, max: number): number {
if (max <= min) return 0
const z = Math.max(min, Math.min(max, zoom))
return Math.log(z / min) / Math.log(max / min)
}
/** A small labelled, monospaced readout — used in the footer status bar. */
function Stat({ label, value }: { label: string; value: string }) {
return (
<span className="flex flex-col leading-tight">
<span className="text-muted-foreground/70 text-[10px] font-medium uppercase tracking-wide">
{label}
</span>
<span className="text-foreground text-sm font-medium tabular-nums">
{value}
</span>
</span>
)
}
/**
* Video trimmer — a business composition built from the timeline primitives.
* The thumbnail strip visualizes the frames, drag the clip's handles to set
* in / out points, play back only the trimmed `[startTime, startTime + duration]`
* window in the preview, then **export the cut as an MP4** via mediabunny's
* `Conversion({ trim })`. Zoom + scroll the timeline; load your own video with
* the picker.
*/
export function VideoTrimmer({
videoUrl = SAMPLE_VIDEO_URL,
pixelsPerSecond = 50,
height = 64,
onChange,
onExport,
}: VideoTrimmerProps) {
// The source: an uploaded file (preferred) or the `videoUrl` prop.
const [file, setFile] = React.useState<File | null>(null)
const [objectUrl, setObjectUrl] = React.useState<string | null>(null)
const src = objectUrl ?? videoUrl
const [cache, setCache] = React.useState<VideoThumbnailCache | null>(null)
const [total, setTotal] = React.useState(0)
const [clip, setClip] = React.useState<TimelineElementResize | null>(null)
const [time, setTime] = React.useState(0)
const [playing, setPlaying] = React.useState(false)
const [exporting, setExporting] = React.useState(false)
const [progress, setProgress] = React.useState(0)
const [zoom, setZoom] = React.useState(1)
const [containerWidth, setContainerWidth] = React.useState(0)
const videoRef = React.useRef<HTMLVideoElement | null>(null)
const onChangeRef = React.useRef(onChange)
onChangeRef.current = onChange
// Auto-fit the zoom once per source, after the width is known.
const didFitRef = React.useRef(false)
const resizeObserverRef = React.useRef<ResizeObserver | null>(null)
// Measure the available width (callback ref re-attaches when the node mounts).
const measureRef = React.useCallback((el: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect()
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth))
ro.observe(el)
resizeObserverRef.current = ro
}, [])
// Object URL lifecycle for an uploaded file.
React.useEffect(() => {
if (!file) return
const url = URL.createObjectURL(file)
setObjectUrl(url)
return () => {
URL.revokeObjectURL(url)
setObjectUrl(null)
}
}, [file])
// Decode the video into a thumbnail cache and read its duration, then reset
// the selection to the full clip.
React.useEffect(() => {
setTotal(0)
setClip(null)
setTime(0)
setPlaying(false)
setCache(null)
didFitRef.current = false
let cancelled = false
let created: VideoThumbnailCache | null = null
void VideoThumbnailCache.fromUrl(src)
.then((c) => {
if (cancelled) {
c.dispose()
return
}
created = c
const meta = c.getMetadata()
if (!meta) return
setCache(c)
setTotal(meta.duration)
setClip({ startTime: 0, duration: meta.duration })
})
.catch(() => undefined)
return () => {
cancelled = true
created?.dispose()
}
}, [src])
const handleTimeUpdate = () => {
const video = videoRef.current
if (video) setTime(video.currentTime)
}
// Zoom that fits the whole clip in the available width. Subtract the track
// padding (half a knob each side) so the filled timeline lands exactly on the
// viewport edge instead of leaving a sliver of scrollable overflow.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(
ZOOM_MAX,
(containerWidth - PLAYHEAD_KNOB) / (total * pixelsPerSecond),
)
: 1
const minZoom = Math.min(ZOOM_MIN, fitZoom)
const maxZoom = Math.max(ZOOM_MAX, fitZoom)
// Fit once, when the width and duration first become known for a source.
React.useEffect(() => {
if (!total || containerWidth <= 0 || didFitRef.current) return
setZoom(fitZoom)
didFitRef.current = true
}, [total, containerWidth, fitZoom])
const updateClip = (next: TimelineElementResize) => {
setClip(next)
onChangeRef.current?.(next)
}
// Trim the selected window to an MP4 blob with mediabunny, then download it.
const exportClip = async () => {
if (!clip) return
setExporting(true)
setProgress(0)
try {
const {
Input,
Output,
Conversion,
BlobSource,
BufferTarget,
Mp4OutputFormat,
ALL_FORMATS,
} = await import('mediabunny')
let source: Blob
if (file) {
source = file
} else {
source = await (await fetch(src)).blob()
}
const input = new Input({
source: new BlobSource(source),
formats: ALL_FORMATS,
})
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
})
const conversion = await Conversion.init({
input,
output,
trim: { start: clip.startTime, end: clip.startTime + clip.duration },
})
conversion.onProgress = setProgress
await conversion.execute()
const mp4 = new Blob([output.target.buffer as ArrayBuffer], {
type: 'video/mp4',
})
onExport?.(mp4)
const name = (file?.name ?? 'video').replace(/\.[^.]+$/, '')
const url = URL.createObjectURL(mp4)
const a = document.createElement('a')
a.href = url
a.download = `${name}-trimmed.mp4`
a.click()
URL.revokeObjectURL(url)
} finally {
setExporting(false)
}
}
if (!cache || !total || !clip) {
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{/* Preview — the video frame. */}
<Skeleton className="mx-auto aspect-video w-full max-w-md" />
{/* Toolbar — play + time on the left, zoom on the right. */}
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="ml-auto h-7 w-44" />
</div>
{/* Timeline — ruler over the thumbnail strip. */}
<div className="bg-muted/30 flex flex-col gap-2 rounded-lg p-3">
<Skeleton className="bg-muted-foreground/15 h-3 w-full" />
<Skeleton
className="bg-muted-foreground/15 w-full"
style={{ height }}
/>
</div>
</CardContent>
{/* Footer — selection stats on the left, actions on the right. */}
<CardFooter className="gap-6">
<Skeleton className="h-8 w-12" />
<Skeleton className="h-8 w-12" />
<Skeleton className="h-8 w-12" />
<div className="ml-auto flex items-center gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
</CardFooter>
</Card>
)
}
const out = clip.startTime + clip.duration
const pps = pixelsPerSecond * zoom
const width = total * pps
const sliderPos = zoomToSlider(zoom, minZoom, maxZoom)
const applySlider = (next: number) => {
didFitRef.current = true
setZoom(sliderToZoom(next, minZoom, maxZoom))
}
const stepZoom = (delta: number) => applySlider(sliderPos + delta)
const fit = () => {
didFitRef.current = true
setZoom(fitZoom)
}
const seek = (next: number) => {
const video = videoRef.current
if (video) video.currentTime = next
setTime(next)
}
// Click / drag anywhere on the timeline to move the playhead and scrub.
// Pointer-downs on the playhead and trim handles stop propagation, so those
// gestures still win over seeking.
const scrubFrom = (event: React.PointerEvent<HTMLDivElement>) => {
const el = event.currentTarget
const toTime = (clientX: number) => {
const rect = el.getBoundingClientRect()
return Math.min(total, Math.max(0, (clientX - rect.left) / pps))
}
seek(toTime(event.clientX))
const onMove = (e: PointerEvent) => seek(toTime(e.clientX))
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (playing) {
video.pause()
setPlaying(false)
return
}
// Restart from the top when parked at the end.
if (time >= total) {
video.currentTime = 0
setTime(0)
}
void video.play()
setPlaying(true)
}
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{/* Preview — plays back only the trimmed window. */}
<div className="bg-muted/30 mx-auto flex aspect-video w-full max-w-md items-center justify-center overflow-hidden rounded-lg">
<video
ref={videoRef}
src={src}
playsInline
onTimeUpdate={handleTimeUpdate}
onEnded={() => setPlaying(false)}
className="h-full w-full object-contain"
/>
</div>
{/* Toolbar — transport on the left, zoom + fit on the right. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<Button
type="button"
size="icon-lg"
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play selection'}
className="rounded-full"
>
{playing ? <Pause /> : <Play className="translate-x-px" />}
</Button>
<span className="text-muted-foreground text-xs tabular-nums">
<span className="text-foreground font-medium">
{formatTime(time)}
</span>{' '}
/ {formatTime(total)}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Zoom out"
onClick={() => stepZoom(-0.1)}
>
<ZoomOut />
</Button>
<div className="w-28">
<Slider
min={0}
max={100}
value={[sliderPos * 100]}
onValueChange={(value) =>
applySlider((Array.isArray(value) ? value[0] : value) / 100)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
title="Zoom in"
onClick={() => stepZoom(0.1)}
>
<ZoomIn />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Fit to width"
onClick={fit}
>
<Maximize2 />
</Button>
<span className="text-muted-foreground w-9 text-right text-xs tabular-nums">
{Math.max(1, Math.round(sliderPos * 100))}%
</span>
</div>
</div>
{/* Timeline — the hero; scrolls horizontally when zoomed past the view. */}
<div className="bg-muted/30 rounded-lg p-3">
<div ref={measureRef}>
<ScrollArea
style={{ height: RULER_HEIGHT + 8 + height + PLAYHEAD_KNOB + 8 }}
>
{/* Pad the scroll content by half a knob on every side so the
playhead circle stays fully visible at the start, end and top.
The inner track is the positioning origin shared by the ruler,
strip and playhead, so they all stay aligned. */}
<div
style={{
width: width + PLAYHEAD_KNOB,
minWidth: '100%',
padding: PLAYHEAD_KNOB / 2,
boxSizing: 'border-box',
}}
>
<div
style={{
position: 'relative',
width,
minWidth: '100%',
cursor: 'pointer',
}}
onPointerDown={scrubFrom}
>
<TimelineRuler
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={RULER_HEIGHT}
/>
<div
className="mt-2"
style={{
position: 'relative',
width,
height,
overflow: 'hidden',
}}
>
{/* Full thumbnail strip — always visible, so you can see what
is being cut away. */}
<ThumbnailStrip
cache={cache}
duration={total}
totalWidth={Math.ceil(width)}
tileWidth={Math.round((height * 16) / 9)}
tileHeight={height}
/>
{/* Spotlight — dim everything outside the selection instead of
hiding it. The large spread shadow follows the rounded
corners, so the dim hugs the selection frame exactly (no
square-vs-rounded notch at the corners). Clipped to the
strip band by the parent's overflow. */}
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: clip.startTime * pps,
width: clip.duration * pps,
borderRadius: 8,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.45)',
pointerEvents: 'none',
}}
/>
{/* Selection window — a transparent frame (border + draggable
trim handles only) so the thumbnails inside stay visible. */}
<TimelineElement
startTime={clip.startTime}
duration={clip.duration}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={height}
minDuration={0.5}
maxEnd={total}
selected
movable
color="transparent"
onResize={updateClip}
/>
</div>
<TimelinePlayhead
currentTime={time}
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
onSeek={seek}
/>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</CardContent>
{/* Footer — selection summary on the left, source / export on the right. */}
<CardFooter className="gap-4">
<Stat label="In" value={formatTime(clip.startTime)} />
<Stat label="Out" value={formatTime(out)} />
<Stat label="Length" value={formatTime(clip.duration)} />
<div className="ml-auto flex items-center gap-2">
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load video
<input
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
<Button
type="button"
onClick={() => void exportClip()}
disabled={exporting}
>
{exporting ? (
<>
<Loader2 className="animate-spin" />
{Math.round(progress * 100)}%
</>
) : (
<>
<Download />
Export clip
</>
)}
</Button>
</div>
</CardFooter>
</Card>
)
}
export default VideoTrimmer
Storyboard Timeline
A business composition built from the timeline primitives: lays out a mix of video and image shots end-to-end on a single absolute time axis, each shot wrapped in its own card (header label, duration, type tag) over a shared timeline-ruler, with a draggable timeline-playhead that follows playback across shots. Videos play in the preview and are driven by the media clock; image stills advance on an rAF clock for their set duration. Zoom widens the strips and ruler while the timeline scrolls; the preview swaps source at shot boundaries.
'use client'
import {
Film,
ImageIcon,
Maximize2,
Pause,
Play,
ZoomIn,
ZoomOut,
} from 'lucide-react'
import * as React from 'react'
import { ThumbnailStrip } from '@/components/thumbnail-strip'
import { TimelinePlayhead } from '@/components/timeline-playhead'
import { TimelineRuler } from '@/components/timeline-ruler'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Slider } from '@/components/ui/slider'
import { VideoThumbnailCache } from '@/lib/video-thumbnail-cache'
/** A storyboard shot: a video (duration from metadata) or a still image shown for `duration` seconds. */
export type StoryboardSource =
| { kind: 'video'; url: string }
| { kind: 'image'; url: string; duration?: number }
const DEFAULT_IMAGE_DURATION = 3
const SAMPLE_SOURCES: StoryboardSource[] = [
{
kind: 'video',
url: 'https://hj-video.zeroaigen.cn/prod/AI/VIDEO/53e46f7949f0d57b77b0cfe47ecf0301.mp4',
},
{
kind: 'image',
url: 'https://hj-img.zeroaigen.cn/prod/USER/IMAGE/bb281336e56e4ef166493e6bb6b83f46.png',
},
{
kind: 'video',
url: 'https://hj-video.zeroaigen.cn/prod/AI/VIDEO/f4e7fdc9807348eedc1e64a963c7433e.mp4',
},
]
const RULER_HEIGHT = 24
const CARD_HEADER_HEIGHT = 22
const STRIP_HEIGHT = 56
const CARD_GAP = 10
// Matches TimelinePlayhead's knob diameter — the track is padded by half this
// on each side so the knob stays fully visible at either end.
const PLAYHEAD_KNOB = 12
const ZOOM_MIN = 0.5
const ZOOM_MAX = 4
type VideoItem = {
kind: 'video'
id: string
url: string
cache: VideoThumbnailCache
duration: number
}
type ImageItem = { kind: 'image'; id: string; url: string; duration: number }
type Item = VideoItem | ImageItem
type LoadState =
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'ready'; items: Item[] }
/** `mm:ss.s` timestamp, e.g. `00:15.5`. */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`
}
/** Absolute time -> item index + item-local time. */
function locate(abs: number, durations: number[]) {
let acc = 0
for (let i = 0; i < durations.length; i++) {
if (abs < acc + durations[i] || i === durations.length - 1)
return { index: i, local: Math.max(0, abs - acc) }
acc += durations[i]
}
return { index: 0, local: 0 }
}
/** Linear slider position (0–1) → exponential zoom, so low values step gently. */
function sliderToZoom(position: number, min: number, max: number): number {
if (max <= min) return min
const p = Math.max(0, Math.min(1, position))
return min * (max / min) ** p
}
/** Inverse of `sliderToZoom`. */
function zoomToSlider(zoom: number, min: number, max: number): number {
if (max <= min) return 0
const z = Math.max(min, Math.min(max, zoom))
return Math.log(z / min) / Math.log(max / min)
}
export interface StoryboardTimelineProps {
/** Shots to lay out end-to-end — videos and/or still images. Defaults to a built-in sample set (remote demo videos + a local still). */
sources?: StoryboardSource[]
/** Base pixels per second at zoom = 1. Default: `50`. */
pixelsPerSecond?: number
}
export function StoryboardTimeline({
sources = SAMPLE_SOURCES,
pixelsPerSecond = 50,
}: StoryboardTimelineProps) {
const [state, setState] = React.useState<LoadState>({ kind: 'loading' })
const [currentTime, setCurrentTime] = React.useState(0) // absolute
const [playing, setPlaying] = React.useState(false)
const [zoom, setZoom] = React.useState(1)
const [containerWidth, setContainerWidth] = React.useState(0)
const videoRef = React.useRef<HTMLVideoElement>(null)
const contentRef = React.useRef<HTMLDivElement>(null)
const pendingSeekRef = React.useRef<number | null>(null)
const wantsPlayRef = React.useRef(false)
// Mirror of currentTime so the image-clock rAF loop reads the latest value.
const currentTimeRef = React.useRef(0)
currentTimeRef.current = currentTime
// Auto-fit the zoom once per load, after the width is known.
const didFitRef = React.useRef(false)
const resizeObserverRef = React.useRef<ResizeObserver | null>(null)
// Measure the available width (callback ref re-attaches when the node mounts).
const measureRef = React.useCallback((el: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect()
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth))
ro.observe(el)
resizeObserverRef.current = ro
}, [])
React.useEffect(() => {
let cancelled = false
let acquired: Item[] = []
const disposeVideos = (items: Item[]) => {
for (const it of items) if (it.kind === 'video') it.cache.dispose()
}
void (async () => {
const results = await Promise.allSettled(
sources.map(async (source, i): Promise<Item> => {
const id = `${i}-${source.url}`
if (source.kind === 'image') {
return {
kind: 'image',
id,
url: source.url,
duration: source.duration ?? DEFAULT_IMAGE_DURATION,
}
}
const cache = await VideoThumbnailCache.fromUrl(source.url)
const meta = cache.getMetadata()
if (!meta) throw new Error('metadata missing after init')
return {
kind: 'video',
id,
url: source.url,
cache,
duration: meta.duration,
}
}),
)
// Caches that decoded before any rejection still hold ImageBitmaps —
// dispose them so a partial failure (or unmount) doesn't leak.
const items = results
.filter(
(r): r is PromiseFulfilledResult<Item> => r.status === 'fulfilled',
)
.map((r) => r.value)
const rejected = results.find(
(r): r is PromiseRejectedResult => r.status === 'rejected',
)
if (cancelled || rejected) {
disposeVideos(items)
if (cancelled) return
const reason = rejected?.reason
setState({
kind: 'error',
message: reason instanceof Error ? reason.message : String(reason),
})
return
}
acquired = items
setState({ kind: 'ready', items })
})()
return () => {
cancelled = true
disposeVideos(acquired)
}
}, [sources])
const durations = React.useMemo(
() => (state.kind === 'ready' ? state.items.map((it) => it.duration) : []),
[state],
)
const starts = React.useMemo(() => {
const out: number[] = []
let acc = 0
for (const d of durations) {
out.push(acc)
acc += d
}
return out
}, [durations])
const total = durations.reduce((s, d) => s + d, 0)
const pps = pixelsPerSecond * zoom
const contentWidth = total * pps
const { index: currentIndex } = locate(currentTime, durations)
// Zoom that fits the whole storyboard in the available width. Subtract the
// track padding (half a knob each side) so the filled timeline lands exactly
// on the viewport edge instead of leaving a sliver of scrollable overflow.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(
ZOOM_MAX,
(containerWidth - PLAYHEAD_KNOB) / (total * pixelsPerSecond),
)
: 1
const minZoom = Math.min(ZOOM_MIN, fitZoom)
const maxZoom = Math.max(ZOOM_MAX, fitZoom)
const sliderPos = zoomToSlider(zoom, minZoom, maxZoom)
// Fit once, when the width and duration first become known.
React.useEffect(() => {
if (!total || containerWidth <= 0 || didFitRef.current) return
setZoom(fitZoom)
didFitRef.current = true
}, [total, containerWidth, fitZoom])
// Video clock: while a video shot is active, the <video> drives currentTime.
React.useEffect(() => {
if (state.kind !== 'ready') return
const active = state.items[currentIndex]
if (!active || active.kind !== 'video') return
const video = videoRef.current
if (!video) return
const onTime = () =>
setCurrentTime(starts[currentIndex] + video.currentTime)
const onPlay = () => setPlaying(true)
// A natural end fires `pause` then `ended`; don't stop there — let `ended`
// hand off to the next shot (incl. the image rAF clock). Only a real manual
// pause (not at the end) should halt playback.
const onPause = () => {
if (!video.ended) setPlaying(false)
}
const onEnded = () => {
if (currentIndex < state.items.length - 1) {
wantsPlayRef.current = true
pendingSeekRef.current = 0
setCurrentTime(starts[currentIndex + 1])
} else {
setPlaying(false)
}
}
video.addEventListener('timeupdate', onTime)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('ended', onEnded)
return () => {
video.removeEventListener('timeupdate', onTime)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('ended', onEnded)
}
}, [state, currentIndex, starts])
// Image clock: stills have no media element, so advance currentTime by rAF
// while playing, and hand off to the next shot when the still's time is up.
React.useEffect(() => {
if (state.kind !== 'ready') return
const active = state.items[currentIndex]
if (!active || !playing || active.kind !== 'image') return
const localEnd = starts[currentIndex] + active.duration
let raf = 0
// Seed `last` from the first frame's timestamp, not performance.now() — the
// rAF clock can report a `now` slightly *before* a performance.now() taken
// here, which would make the first dt negative and nudge currentTime back
// across the shot boundary, bouncing the playhead onto the previous shot.
let last: number | null = null
const tick = (now: number) => {
const dt = last === null ? 0 : (now - last) / 1000
last = now
const next = currentTimeRef.current + dt
if (next >= localEnd) {
if (currentIndex < state.items.length - 1) {
wantsPlayRef.current = true
pendingSeekRef.current = 0
setCurrentTime(starts[currentIndex + 1])
} else {
setCurrentTime(localEnd)
setPlaying(false)
}
return
}
setCurrentTime(next)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [playing, currentIndex, state, starts])
const togglePlay = () => {
if (state.kind !== 'ready') return
const active = state.items[currentIndex]
if (!active) return
if (active.kind === 'video') {
const video = videoRef.current
if (!video) return
if (video.paused) {
wantsPlayRef.current = true
void video.play()
} else {
wantsPlayRef.current = false
video.pause()
}
} else {
setPlaying((p) => {
const next = !p
wantsPlayRef.current = next
return next
})
}
}
const seek = (abs: number) => {
const { index, local } = locate(abs, durations)
setCurrentTime(abs)
if (state.kind !== 'ready') return
if (index === currentIndex) {
const active = state.items[index]
if (active.kind === 'video') {
const video = videoRef.current
if (video) video.currentTime = local
}
} else {
pendingSeekRef.current = local
}
}
const scrubFrom = (event: React.PointerEvent<HTMLDivElement>) => {
if (!contentRef.current) return
const rect = contentRef.current.getBoundingClientRect()
seek(Math.min(total, Math.max(0, (event.clientX - rect.left) / pps)))
}
const handleLoadedMetadata = () => {
const video = videoRef.current
if (!video) return
if (pendingSeekRef.current !== null) {
video.currentTime = pendingSeekRef.current
pendingSeekRef.current = null
}
if (wantsPlayRef.current) void video.play()
}
const applySlider = (next: number) => {
didFitRef.current = true
setZoom(sliderToZoom(next, minZoom, maxZoom))
}
const stepZoom = (delta: number) => applySlider(sliderPos + delta)
const fit = () => {
didFitRef.current = true
setZoom(fitZoom)
}
if (state.kind === 'loading') {
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
<Skeleton className="h-64 w-full rounded-md" />
<Skeleton className="h-24 w-full rounded-md" />
</CardContent>
</Card>
)
}
if (state.kind === 'error') {
return (
<Card className="w-full">
<CardContent className="pt-(--card-spacing)">
<p className="text-destructive text-sm">
Could not load the storyboard.
</p>
</CardContent>
</Card>
)
}
if (state.items.length === 0) {
return (
<Card className="w-full">
<CardContent className="pt-(--card-spacing)">
<p className="text-muted-foreground text-sm">No shots to display.</p>
</CardContent>
</Card>
)
}
const trackHeight = CARD_HEADER_HEIGHT + STRIP_HEIGHT
const stripTileWidth = Math.round((STRIP_HEIGHT * 16) / 9)
const active = state.items[currentIndex]
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{active.kind === 'video' ? (
<video
key={currentIndex}
ref={videoRef}
src={active.url}
crossOrigin="anonymous"
playsInline
onLoadedMetadata={handleLoadedMetadata}
className="h-64 w-full rounded-md bg-black object-contain"
>
<track kind="captions" />
</video>
) : (
// biome-ignore lint/performance/noImgElement: shots take arbitrary / blob URLs, next/image would not fit a copyable block
<img
key={currentIndex}
src={active.url}
alt={`Shot ${currentIndex + 1}`}
className="h-64 w-full rounded-md bg-black object-contain"
/>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-3 text-sm">
<Button
type="button"
size="icon-lg"
className="rounded-full"
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause /> : <Play className="translate-x-px" />}
</Button>
<span className="text-muted-foreground text-xs tabular-nums">
<span className="text-foreground font-medium">
{formatTime(currentTime)}
</span>{' '}
/ {formatTime(total)}
</span>
<span className="text-muted-foreground">
shot {currentIndex + 1} of {state.items.length}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Zoom out"
onClick={() => stepZoom(-0.1)}
>
<ZoomOut />
</Button>
<div className="w-28">
<Slider
min={0}
max={100}
value={[sliderPos * 100]}
onValueChange={(value) =>
applySlider((Array.isArray(value) ? value[0] : value) / 100)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
title="Zoom in"
onClick={() => stepZoom(0.1)}
>
<ZoomIn />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Fit to width"
onClick={fit}
>
<Maximize2 />
</Button>
<span className="text-muted-foreground w-9 text-right text-xs tabular-nums">
{Math.max(1, Math.round(sliderPos * 100))}%
</span>
</div>
</div>
{/* Storyboard timeline — each shot is its own card on a shared ruler. */}
<div className="bg-muted/30 rounded-lg p-3">
<div ref={measureRef}>
<ScrollArea
style={{
height: RULER_HEIGHT + 8 + trackHeight + PLAYHEAD_KNOB + 8,
}}
>
{/* Pad the scroll content by half a knob on every side so the
playhead circle stays fully visible at the start, end and top.
The inner track is the positioning origin shared by the ruler,
cards and playhead, so they all stay aligned. */}
<div
style={{
width: contentWidth + PLAYHEAD_KNOB,
minWidth: '100%',
padding: PLAYHEAD_KNOB / 2,
boxSizing: 'border-box',
}}
>
<div
ref={contentRef}
style={{
position: 'relative',
width: contentWidth,
minWidth: '100%',
cursor: 'pointer',
}}
onPointerDown={scrubFrom}
>
<TimelineRuler
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={RULER_HEIGHT}
/>
<div
className="mt-2"
style={{
position: 'relative',
width: contentWidth,
height: trackHeight,
}}
>
{state.items.map((item, i) => {
const slotLeft = starts[i] * pps
const slotWidth = item.duration * pps
const cardWidth = Math.max(0, slotWidth - CARD_GAP)
const isActive = i === currentIndex
return (
<div
key={item.id}
className="bg-card overflow-hidden rounded-md border"
style={{
position: 'absolute',
top: 0,
left: slotLeft + CARD_GAP / 2,
width: cardWidth,
height: trackHeight,
borderColor: isActive
? 'var(--primary)'
: 'var(--border)',
boxShadow: isActive
? '0 0 0 1px var(--primary)'
: undefined,
}}
>
<div
className="text-muted-foreground flex items-center gap-1.5 overflow-hidden whitespace-nowrap px-2 text-[11px]"
style={{
height: CARD_HEADER_HEIGHT,
lineHeight: `${CARD_HEADER_HEIGHT}px`,
}}
>
<span className="text-foreground font-medium">
Shot {i + 1}
</span>
<span className="tabular-nums">
{formatTime(item.duration)}
</span>
<span className="ml-auto inline-flex items-center gap-1">
{item.kind === 'video' ? (
<>
<Film className="size-3" />
video
</>
) : (
<>
<ImageIcon className="size-3" />
image
</>
)}
</span>
</div>
{item.kind === 'video' ? (
<ThumbnailStrip
cache={item.cache}
duration={item.duration}
totalWidth={cardWidth}
tileWidth={stripTileWidth}
tileHeight={STRIP_HEIGHT}
/>
) : (
<div
style={{
width: cardWidth,
height: STRIP_HEIGHT,
backgroundImage: `url(${item.url})`,
backgroundSize: `${stripTileWidth}px ${STRIP_HEIGHT}px`,
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left center',
}}
/>
)}
</div>
)
})}
</div>
<TimelinePlayhead
currentTime={currentTime}
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
onSeek={seek}
/>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</CardContent>
</Card>
)
}
export default StoryboardTimeline
Image Cropper
A business composition built on the image-crop primitive: load an image and frame a crop inline, with the selection rendered live as a real image in the preview beside it — that preview is the cropped result. Switch to a circular avatar crop, rotate the source in 90° steps, or lock the box to an aspect-ratio preset; the output is validated against min/max source-pixel bounds (the readout turns red until it fits) and downloads at full resolution.
'use client'
import {
Circle,
Download,
RotateCcw,
RotateCw,
Square,
Undo2,
Upload,
} from 'lucide-react'
import * as React from 'react'
import type { Crop, PercentCrop, PixelCrop } from '@/components/image-crop'
import {
centerCrop,
convertToPixelCrop,
ImageCrop,
makeAspectCrop,
} from '@/components/image-crop'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
const SAMPLE_IMAGE_URL =
'https://hj-img.zeroaigen.cn/prod/USER/IMAGE/aa585c3201eaf54d0ce696484ab4abfb.jpg'
/** Aspect-ratio presets. `undefined` is a free-form crop. */
const ASPECT_RATIOS: { label: string; value: number | undefined }[] = [
{ label: 'Free', value: undefined },
{ label: '1:1', value: 1 },
{ label: '16:9', value: 16 / 9 },
{ label: '9:16', value: 9 / 16 },
{ label: '4:3', value: 4 / 3 },
{ label: '3:4', value: 3 / 4 },
]
export interface ImageCropperProps {
/** Image to load and crop. Falls back to a bundled sample. */
imageUrl?: string
/** Reject a crop whose output is narrower than this, in source pixels. */
minWidth?: number
/** Reject a crop whose output is shorter than this, in source pixels. */
minHeight?: number
/** Reject a crop whose output is wider than this, in source pixels. */
maxWidth?: number
/** Reject a crop whose output is taller than this, in source pixels. */
maxHeight?: number
/** Fired with the cropped image when it is downloaded. */
onCrop?: (file: File) => void
}
/** A centered crop at 80% of the shorter side, honoring `aspect` when set. */
function initialCrop(
aspect: number | undefined,
width: number,
height: number,
): PercentCrop {
const box: PercentCrop = aspect
? makeAspectCrop({ unit: '%', width: 80 }, aspect, width, height)
: { unit: '%', x: 0, y: 0, width: 80, height: 80 }
return centerCrop(box, width, height)
}
const MIME_BY_EXT: Record<string, string> = {
png: 'image/png',
webp: 'image/webp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
}
/** Pick an output mime + extension from a source filename, defaulting to JPEG. */
function outputFormat(name: string): { mimeType: string; ext: string } {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
const mimeType = MIME_BY_EXT[ext] ?? 'image/jpeg'
return { mimeType, ext: mimeType === 'image/jpeg' ? 'jpg' : ext }
}
/**
* Paint `crop` (in displayed-image pixels) from the source image onto `canvas`
* at full source resolution — so the canvas *is* the cropped picture, both for
* the live preview (scaled down by CSS) and for the download. When `round`, the
* paint is clipped to an ellipse, leaving transparent corners for an avatar.
*
* `crop.x/y` are reported relative to the media box, whose origin coincides
* with the top-left of the displayed image, so scaling by the image's own
* displayed size (not the box) keeps the painted region matched to the
* on-screen selection even when the image is letterboxed inside the box.
*/
function drawPreview(
canvas: HTMLCanvasElement,
image: HTMLImageElement,
crop: PixelCrop,
round: boolean,
) {
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
const w = Math.round(crop.width * scaleX)
const h = Math.round(crop.height * scaleY)
if (w <= 0 || h <= 0) return
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, w, h)
if (round) {
ctx.beginPath()
ctx.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2)
ctx.clip()
}
ctx.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
w,
h,
)
}
/**
* Image cropper — a business composition built on the image-crop primitive.
* Load a picture and frame a crop inline; the selection renders live as a real
* image in the preview beside it — that preview *is* the cropped result. Switch
* to a circular avatar crop, rotate the source in 90° steps, or lock the box to
* an aspect-ratio preset. The crop is rejected unless its output lands inside
* the `[min, max]` source-pixel bounds — the readout turns red until it does —
* then it can be downloaded at full resolution.
*/
export function ImageCropper({
imageUrl = SAMPLE_IMAGE_URL,
minWidth = 200,
minHeight = 200,
maxWidth = 4096,
maxHeight = 4096,
onCrop,
}: ImageCropperProps) {
// The source: an uploaded (or rotated) file (preferred) or the `imageUrl` prop.
const [file, setFile] = React.useState<File | null>(null)
const [objectUrl, setObjectUrl] = React.useState<string | null>(null)
const src = objectUrl ?? imageUrl
const fileName = file?.name ?? 'image.jpg'
const [aspect, setAspect] = React.useState<number | undefined>(undefined)
const [round, setRound] = React.useState(false)
const [crop, setCrop] = React.useState<Crop>()
const [completed, setCompleted] = React.useState<PixelCrop>()
const imgRef = React.useRef<HTMLImageElement>(null)
const canvasRef = React.useRef<HTMLCanvasElement>(null)
// Object-URL lifecycle for an uploaded/rotated file.
React.useEffect(() => {
if (!file) return
const url = URL.createObjectURL(file)
setObjectUrl(url)
return () => {
URL.revokeObjectURL(url)
setObjectUrl(null)
}
}, [file])
// Output dimensions in source pixels — what validation and the readout use.
const img = imgRef.current
const output =
completed && img && completed.width > 0
? {
width: Math.round(completed.width * (img.naturalWidth / img.width)),
height: Math.round(
completed.height * (img.naturalHeight / img.height),
),
}
: null
const error = output
? output.width < minWidth || output.height < minHeight
? `Too small — minimum ${minWidth}×${minHeight}`
: output.width > maxWidth || output.height > maxHeight
? `Too large — maximum ${maxWidth}×${maxHeight}`
: null
: null
// Repaint the preview canvas whenever the selection or shape changes.
React.useEffect(() => {
if (
canvasRef.current &&
imgRef.current &&
completed &&
completed.width > 0
) {
drawPreview(canvasRef.current, imgRef.current, completed, round)
}
}, [completed, round])
// Seed the crop once the image is laid out, so the box is framed on load.
const seed = (next: number | undefined, w: number, h: number) => {
const pc = initialCrop(next, w, h)
setCrop(pc)
setCompleted(convertToPixelCrop(pc, w, h))
}
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget
seed(aspect, width, height)
}
const reseed = (next: number | undefined) => {
const el = imgRef.current
if (el) seed(next, el.width, el.height)
}
const changeAspect = (next: number | undefined) => {
setAspect(next)
reseed(next)
}
// Circle mode is square by definition — lock the aspect to 1:1.
const changeShape = (next: boolean) => {
setRound(next)
if (next) {
setAspect(1)
reseed(1)
}
}
// Bake a 90° rotation into the source by feeding back a rotated file, so the
// crop, preview, and download all keep working against an upright image.
const rotate = (dir: 'cw' | 'ccw') => {
const image = imgRef.current
const w = image?.naturalWidth ?? 0
const h = image?.naturalHeight ?? 0
if (!image || !w || !h) return
const canvas = document.createElement('canvas')
canvas.width = h
canvas.height = w
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate(((dir === 'cw' ? 1 : -1) * Math.PI) / 2)
ctx.drawImage(image, -w / 2, -h / 2)
const { mimeType } = outputFormat(fileName)
canvas.toBlob(
(blob) => {
if (!blob) return
setFile(new File([blob], fileName, { type: mimeType }))
setCompleted(undefined)
},
mimeType,
mimeType === 'image/png' ? 1 : 0.95,
)
}
const download = () => {
const canvas = canvasRef.current
if (!canvas || !completed || error) return
const { mimeType, ext } = round
? { mimeType: 'image/png', ext: 'png' }
: outputFormat(fileName)
canvas.toBlob(
(blob) => {
if (!blob) return
const base = fileName.replace(/\.[^.]+$/, '')
const cropped = new File([blob], `${base}-cropped.${ext}`, {
type: mimeType,
})
onCrop?.(cropped)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = cropped.name
a.click()
URL.revokeObjectURL(url)
},
mimeType,
mimeType === 'image/png' ? 1 : 0.95,
)
}
return (
<Card className="w-full max-w-3xl">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
<div className="grid gap-4 sm:h-80 sm:grid-cols-[1fr_20rem]">
{/* Crop area. `min-w-0` lets the 1fr column shrink so the image is
contained (not clipped) and the dim mask covers all of it. */}
<div className="bg-muted/40 flex min-h-64 min-w-0 items-center justify-center overflow-hidden rounded-lg p-2 sm:min-h-0">
<ImageCrop
crop={crop}
aspect={aspect}
circularCrop={round}
ruleOfThirds={!round}
minWidth={20}
minHeight={20}
onChange={(pixel, percent) => {
setCrop(percent)
setCompleted(pixel)
}}
className="sm:max-h-[calc(20rem_-_1rem)]"
>
{/* biome-ignore lint/performance/noImgElement: registry component, no next/image */}
<img
ref={imgRef}
src={src}
alt="Crop source"
crossOrigin="anonymous"
onLoad={onImageLoad}
/>
</ImageCrop>
</div>
{/* Live preview — the cropped result. */}
<div className="flex min-h-0 flex-col gap-2">
<span className="text-muted-foreground/70 text-[10px] font-medium uppercase tracking-wide">
Preview
</span>
<div className="bg-muted/40 flex min-h-48 flex-1 items-center justify-center overflow-hidden rounded-lg p-2">
<canvas
ref={canvasRef}
className="max-h-full max-w-full object-contain"
/>
</div>
{output && (
<p
className={
error
? 'text-destructive text-xs leading-tight'
: 'text-muted-foreground text-xs leading-tight'
}
>
{error ?? `${output.width}×${output.height}px`}
</p>
)}
</div>
</div>
{/* Shape + transforms. */}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={round ? 'outline' : 'secondary'}
onClick={() => changeShape(false)}
>
<Square />
Rectangle
</Button>
<Button
size="sm"
variant={round ? 'secondary' : 'outline'}
onClick={() => changeShape(true)}
>
<Circle />
Circle
</Button>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant="outline"
aria-label="Rotate left"
onClick={() => rotate('ccw')}
>
<RotateCcw />
</Button>
<Button
size="sm"
variant="outline"
aria-label="Rotate right"
onClick={() => rotate('cw')}
>
<RotateCw />
</Button>
<Button
size="sm"
variant="outline"
aria-label="Reset crop"
onClick={() => reseed(aspect)}
>
<Undo2 />
Reset
</Button>
</div>
</div>
{/* Aspect presets — irrelevant once the crop is locked to a circle. */}
{!round && (
<div className="flex flex-wrap gap-2">
{ASPECT_RATIOS.map(({ label, value }) => (
<Button
key={label}
size="sm"
variant={aspect === value ? 'secondary' : 'outline'}
onClick={() => changeAspect(value)}
>
{label}
</Button>
))}
</div>
)}
</CardContent>
<CardFooter>
<div className="ml-auto flex items-center gap-2">
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) {
setFile(next)
setCompleted(undefined)
}
}}
/>
</Button>
<Button onClick={download} disabled={!completed || !!error}>
<Download />
Download
</Button>
</div>
</CardFooter>
</Card>
)
}
export default ImageCropper
Image Editor
A full single-image editor built on fabric.js, wrapped in ikui's Base UI toolbar. Annotate with text, shapes, arrows, and image overlays; free-draw; pixelate a region for a privacy mosaic; tune brightness, contrast, and saturation; apply filter presets; crop (via the image-crop primitive) and resize; rotate, flip, undo, and redo; then export to PNG or JPEG. The fabric canvas is created from a lazily-imported module inside an effect, so the browser-only library never runs during SSR.
'use client'
import type { DragEndEvent } from '@dnd-kit/core'
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// Types only — the runtime module is loaded lazily in an effect so this client
// component never imports fabric (a browser-only library) during SSR/prerender.
import type { Canvas, FabricImage, FabricObject } from 'fabric'
import {
AlignCenter,
AlignLeft,
AlignRight,
ArrowUpRight,
Bold,
BringToFront,
Check,
Circle,
Code2,
Copy,
Crop as CropIcon,
Download,
Eye,
EyeOff,
FlipHorizontal2,
FlipVertical2,
Grid3x3,
GripVertical,
Hand,
History,
ImageUp,
Italic,
Layers as LayersIcon,
Lock,
LockOpen,
Minus,
MoreHorizontal,
MousePointer2,
Pencil,
Plus,
Redo2,
RotateCcw,
RotateCw,
SendToBack,
Shapes,
Square,
Stamp,
Trash2,
Type,
Undo2,
Upload,
} from 'lucide-react'
import * as React from 'react'
import type { Crop, PercentCrop } from '@/components/image-crop'
import {
centerCrop,
convertToPixelCrop,
ImageCrop,
makeAspectCrop,
} from '@/components/image-crop'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Field,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
type FabricModule = typeof import('fabric')
const SAMPLE_IMAGE_URL =
'https://hj-img.zeroaigen.cn/prod/USER/IMAGE/aa585c3201eaf54d0ce696484ab4abfb.jpg'
/** The stage is fit inside this box when it has not been measured yet. */
const MAX_W = 760
const MAX_H = 460
/** Tonal adjustments, normalized to fabric's -1..1 filter range. */
interface Adjust {
brightness: number
contrast: number
saturation: number
}
const NEUTRAL: Adjust = { brightness: 0, contrast: 0, saturation: 0 }
/** Zoom is a multiplier over the contain-fit view; 1 = fully fit. */
const ZOOM_MIN = 1
const ZOOM_MAX = 8
const ZOOM_STEP = 1.25
/**
* Clamp the pan offset so the zoomed image always fills the viewport — no empty
* gaps appear at the edges. Mirrors filerobot's `dragBoundFunc`:
* `offset ∈ [size * (1 - zoom), 0]`.
*/
function clampViewport(canvas: Canvas) {
const vpt = canvas.viewportTransform
if (!vpt) return
const z = canvas.getZoom()
vpt[4] = Math.min(0, Math.max(vpt[4], canvas.width * (1 - z)))
vpt[5] = Math.min(0, Math.max(vpt[5], canvas.height * (1 - z)))
canvas.setViewportTransform(vpt)
}
/** Filter presets — each builds the fabric filter stack it layers on the base. */
type FabricFilters = FabricModule['filters']
type FilterList = NonNullable<FabricImage['filters']>
interface PresetDef {
id: string
label: string
build: (f: FabricFilters) => FilterList
}
const PRESETS: PresetDef[] = [
{ id: 'original', label: 'Original', build: () => [] },
{ id: 'invert', label: 'Invert', build: (f) => [new f.Invert()] },
{ id: 'bw', label: 'B&W', build: (f) => [new f.Grayscale()] },
{ id: 'sepia', label: 'Sepia', build: (f) => [new f.Sepia()] },
{
id: 'vivid',
label: 'Vivid',
build: (f) => [
new f.Saturation({ saturation: 0.4 }),
new f.Contrast({ contrast: 0.12 }),
],
},
{
id: 'clarendon',
label: 'Clarendon',
build: (f) => [
new f.Contrast({ contrast: 0.15 }),
new f.Saturation({ saturation: 0.3 }),
new f.HueRotation({ rotation: -0.06 }),
],
},
{
id: 'gingham',
label: 'Gingham',
build: (f) => [
new f.Saturation({ saturation: -0.25 }),
new f.Contrast({ contrast: -0.08 }),
new f.Brightness({ brightness: 0.05 }),
],
},
{
id: 'cool',
label: 'Cool',
build: (f) => [new f.HueRotation({ rotation: -0.2 })],
},
{
id: 'warm',
label: 'Warm',
build: (f) => [new f.HueRotation({ rotation: 0.12 })],
},
]
/** Filter-strip thumbnail render size (downscaled, so filtering is cheap). */
const THUMB_W = 160
const THUMB_H = 120
/** Compact muted label styling shared by the inspector's `FieldLabel`s. */
const FIELD_LABEL = 'text-muted-foreground text-xs font-normal'
/** Web-safe font family choices for the text annotation toolbar. */
const FONTS = [
'sans-serif',
'serif',
'monospace',
'Arial',
'Georgia',
'Times New Roman',
'Courier New',
'Verdana',
]
/** Text alignment cycle order (the toolbar button steps through these). */
const ALIGNS = ['left', 'center', 'right'] as const
const ALIGN_ICON = {
left: AlignLeft,
center: AlignCenter,
right: AlignRight,
} as const
/** Crop aspect-ratio presets (`undefined` = free-form). */
const CROP_ASPECTS: { label: string; value: number | undefined }[] = [
{ label: 'Free', value: undefined },
{ label: '1:1', value: 1 },
{ label: '16:9', value: 16 / 9 },
{ label: '9:16', value: 9 / 16 },
{ label: '4:3', value: 4 / 3 },
{ label: '3:4', value: 3 / 4 },
]
/**
* The active pointer tool. Each tool arms a single canvas pointer behaviour
* (select / place-text / drag-a-shape / pen / mosaic / crop / pan), replacing the
* old "tab = tool category" model.
*/
type Tool = 'select' | 'text' | 'shape' | 'draw' | 'redact' | 'crop' | 'hand'
const TOOLS: {
value: Tool
label: string
Icon: React.ComponentType<{ className?: string }>
hotkey: string
}[] = [
{ value: 'select', label: 'Select', Icon: MousePointer2, hotkey: 'v' },
{ value: 'text', label: 'Text', Icon: Type, hotkey: 't' },
{ value: 'shape', label: 'Shape', Icon: Shapes, hotkey: 'r' },
{ value: 'draw', label: 'Draw', Icon: Pencil, hotkey: 'p' },
{ value: 'redact', label: 'Redact', Icon: Grid3x3, hotkey: 'm' },
{ value: 'crop', label: 'Crop', Icon: CropIcon, hotkey: 'c' },
{ value: 'hand', label: 'Hand', Icon: Hand, hotkey: 'h' },
]
/** Shape kind drawn by the Shape tool (drag to size on the canvas). */
type ShapeKind = 'rect' | 'ellipse' | 'arrow'
const SHAPE_KINDS: {
value: ShapeKind
label: string
Icon: React.ComponentType<{ className?: string }>
}[] = [
{ value: 'rect', label: 'Rectangle', Icon: Square },
{ value: 'ellipse', label: 'Ellipse', Icon: Circle },
{ value: 'arrow', label: 'Arrow', Icon: ArrowUpRight },
]
/**
* Mosaic brush state shared between React and the canvas pointer handlers (which
* are bound once at mount and therefore read live values through this ref).
*
* - `layerCanvas` is an offscreen canvas the brush paints onto; `layer` wraps it
* as a fabric image sitting on top of everything.
* - `pixSrc` is a full-canvas pixelated copy of the background; each stroke
* reveals it through a round clip, so the mosaic always samples the original
* pixels rather than already-pixelated ones.
*/
interface MosaicState {
on: boolean
painting: boolean
width: number
block: number
layer: FabricImage | null
layerCanvas: HTMLCanvasElement | null
pixSrc: HTMLCanvasElement | null
}
/**
* One history frame: the fabric scene plus the canvas pixel size and derived
* natural dims / fit scale (which `canvas.toJSON()` does not serialize).
*/
interface Snapshot {
json: string
width: number
height: number
dims: { w: number; h: number }
fitScale: number
}
export interface ImageEditorProps {
/** Base image to edit. Falls back to a bundled sample. */
imageUrl?: string
/** Fired with the exported file when the user downloads it. */
onExport?: (file: File) => void
}
/** A row in the Layers panel — one fabric object (top-first in the list). */
interface Layer {
id: string
label: string
/** User-given name (`obj.layerName`); undefined falls back to the type label. */
name?: string
Icon: React.ComponentType<{ className?: string }>
visible: boolean
active: boolean
obj: FabricObject
}
/** Human label + icon for an object, by fabric type. */
function layerMeta(
obj: FabricObject,
fabric: FabricModule,
mosaicLayer: FabricImage | null,
): { label: string; Icon: Layer['Icon'] } {
if (obj === mosaicLayer) return { label: 'Mosaic', Icon: Grid3x3 }
if (
obj instanceof fabric.IText ||
obj instanceof fabric.Textbox ||
obj instanceof fabric.FabricText
)
return { label: 'Text', Icon: Type }
if (obj instanceof fabric.Rect) return { label: 'Rectangle', Icon: Square }
if (obj instanceof fabric.Ellipse) return { label: 'Ellipse', Icon: Circle }
if (obj instanceof fabric.Group) return { label: 'Arrow', Icon: ArrowUpRight }
if (obj instanceof fabric.Path) return { label: 'Drawing', Icon: Pencil }
if (obj instanceof fabric.FabricImage)
return { label: 'Image', Icon: ImageUp }
return { label: 'Object', Icon: Square }
}
/** One sortable Layers-panel row. */
function LayerRow({
layer,
onSelect,
onToggleVisible,
onRename,
onDuplicate,
onForward,
onBack,
onDelete,
}: {
layer: Layer
onSelect: () => void
onToggleVisible: () => void
onRename: (name: string) => void
onDuplicate: () => void
onForward: () => void
onBack: () => void
onDelete: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: layer.id })
const { Icon } = layer
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState('')
const startEdit = () => {
setDraft(layer.name ?? '')
setEditing(true)
}
const commit = () => {
setEditing(false)
onRename(draft)
}
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
'flex items-center gap-1.5 rounded-md border px-1.5 py-1 text-sm',
layer.active
? 'border-primary bg-primary/5'
: 'hover:bg-muted border-transparent',
isDragging && 'opacity-60',
!layer.visible && 'opacity-50',
)}
>
<button
type="button"
onClick={onToggleVisible}
className="text-muted-foreground hover:text-foreground"
aria-label={layer.visible ? 'Hide' : 'Show'}
title={layer.visible ? 'Hide' : 'Show'}
>
{layer.visible ? (
<Eye className="size-4" />
) : (
<EyeOff className="size-4" />
)}
</button>
<button
type="button"
className="text-muted-foreground touch-none cursor-grab active:cursor-grabbing"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<Icon className="size-4 shrink-0" />
{editing ? (
<input
autoFocus
value={draft}
placeholder={layer.label}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.target.select()}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') commit()
else if (e.key === 'Escape') setEditing(false)
}}
className="border-input min-w-0 flex-1 rounded border bg-transparent px-1 text-sm outline-none"
aria-label="Layer name"
/>
) : (
<button
type="button"
onClick={onSelect}
onDoubleClick={startEdit}
className="min-w-0 flex-1 truncate text-left"
title="Double-click to rename"
>
{layer.label}
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="text-muted-foreground hover:text-foreground"
aria-label="Layer actions"
title="Actions"
>
<MoreHorizontal className="size-4" />
</button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={startEdit}>
<Pencil /> Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDuplicate}>
<Copy /> Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={onForward}>
<BringToFront /> Bring forward
</DropdownMenuItem>
<DropdownMenuItem onClick={onBack}>
<SendToBack /> Send back
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
/**
* Image editor — a full single-image editor built on fabric.js, wrapped in
* ikui's Base UI toolbar. Annotate with text / shapes / arrows / image overlays,
* free-draw, pixelate a region (mosaic), tune brightness / contrast / saturation,
* apply filter presets, rotate / flip, undo / redo, and export to PNG or JPEG.
*
* fabric is browser-only, so the canvas is created from a lazily-imported module
* inside an effect (never during render) — safe under Next's RSC / static export.
*/
export function ImageEditor({
imageUrl = SAMPLE_IMAGE_URL,
onExport,
}: ImageEditorProps) {
const canvasElRef = React.useRef<HTMLCanvasElement>(null)
const stageRef = React.useRef<HTMLDivElement>(null)
const fabricRef = React.useRef<Canvas | null>(null)
const modRef = React.useRef<FabricModule | null>(null)
const mosaicRef = React.useRef<MosaicState>({
on: false,
painting: false,
width: 28,
block: 12,
layer: null,
layerCanvas: null,
pixSrc: null,
})
// Drag-to-pan when zoomed in (filerobot-style). `spaceHeld` forces panning in
// any tool; `prevDrawing` restores the pen after a space-pan in draw mode.
const panRef = React.useRef({
panning: false,
spaceHeld: false,
prevDrawing: false,
lastX: 0,
lastY: 0,
})
// History snapshots + cursor; `restoring` suppresses the change listener while
// we re-load a snapshot. Each snapshot also stores the canvas pixel dimensions
// and derived dims/fitScale — `toJSON()` omits canvas size, so without these a
// crop could not be undone/reset correctly.
const historyRef = React.useRef<{ stack: Snapshot[]; index: number }>({
stack: [],
index: -1,
})
const restoringRef = React.useRef(false)
const overlayInputRef = React.useRef<HTMLInputElement>(null)
// Mirrors of dims/fitScale state so `pushHistory` can read them without
// entering the mount-effect deps (which would remount the canvas).
const dimsRef = React.useRef<{ w: number; h: number } | null>(null)
const fitScaleRef = React.useRef(1)
// Latest undo/redo, kept current each render so the mount-bound key handler
// calls them without needing them in its dependency array.
const undoRef = React.useRef<() => void>(() => {})
const redoRef = React.useRef<() => void>(() => {})
// Reposition the floating selection toolbar; set in the mount effect, called
// from component-scope code (e.g. after a button zoom).
const selSyncRef = React.useRef<() => void>(() => {})
// Rebuild the Layers list; set in the mount effect.
const layersSyncRef = React.useRef<() => void>(() => {})
// Transient per-object ids for the sortable layer list (rebuilt after history
// loads, where fabric creates fresh object instances).
const layerIdsRef = React.useRef(new WeakMap<FabricObject, string>())
const layerSeqRef = React.useRef(0)
// Live mirrors read by the mount-bound canvas pointer handlers (which capture
// first-render closures, so they must read current tool / color / shape kind
// through refs). `activateToolRef` lets a handler switch tools after a create;
// `creatingRef` holds the object being drag-drawn.
const toolRef = React.useRef<Tool>('select')
const colorRef = React.useRef('#ef4444')
const shapeKindRef = React.useRef<ShapeKind>('rect')
const activateToolRef = React.useRef<(t: Tool) => void>(() => {})
const creatingRef = React.useRef<{
obj: FabricObject
sx: number
sy: number
kind: ShapeKind
} | null>(null)
const [ready, setReady] = React.useState(false)
const [resetOpen, setResetOpen] = React.useState(false)
const [exportOpen, setExportOpen] = React.useState(false)
const [tool, setTool] = React.useState<Tool>('select')
const [shapeKind, setShapeKind] = React.useState<ShapeKind>('rect')
const [adjust, setAdjust] = React.useState<Adjust>(NEUTRAL)
const [preset, setPreset] = React.useState('original')
// Per-preset preview thumbnails (data URLs), rebuilt when the base changes.
const [thumbs, setThumbs] = React.useState<Record<string, string>>({})
const [color, setColor] = React.useState('#ef4444')
const [penWidth, setPenWidth] = React.useState(6)
const [mosaicBlock, setMosaicBlock] = React.useState(12)
const [mosaicWidth, setMosaicWidth] = React.useState(28)
const [canUndo, setCanUndo] = React.useState(false)
const [canRedo, setCanRedo] = React.useState(false)
// Natural (source-pixel) dimensions of the base image, shown in the top bar.
const [dims, setDims] = React.useState<{ w: number; h: number } | null>(null)
// `fitScale` is displayed-over-natural at zoom 1; `zoom` is the user multiplier.
const [fitScale, setFitScale] = React.useState(1)
const [zoom, setZoom] = React.useState(1)
// Output dimensions (source pixels) for the Resize tool; applied on export.
const [resize, setResize] = React.useState({
width: 0,
height: 0,
lock: true,
})
// Crop overlay: a snapshot of the canvas shown under the image-crop primitive.
const [cropSnapshot, setCropSnapshot] = React.useState<string | null>(null)
const [crop, setCrop] = React.useState<Crop>()
const [cropAspect, setCropAspect] = React.useState<number | undefined>(
undefined,
)
// The active object's editable props, driving the inspector's Object section.
// Null when nothing is selected (inspector then shows the Canvas section).
const [sel, setSel] = React.useState<{
kind: 'text' | 'shape' | 'other'
showColor: boolean
strokeWidth: number
fontFamily: string
fontSize: number
bold: boolean
italic: boolean
align: (typeof ALIGNS)[number]
opacity: number
} | null>(null)
const [layers, setLayers] = React.useState<Layer[]>([])
// Keep the handler-facing mirrors current each render.
toolRef.current = tool
colorRef.current = color
shapeKindRef.current = shapeKind
// --- history -------------------------------------------------------------
const syncHistoryFlags = React.useCallback(() => {
const h = historyRef.current
setCanUndo(h.index > 0)
setCanRedo(h.index < h.stack.length - 1)
}, [])
/** Fit `img` to fill the stage (contain), size the canvas to it, set as bg. */
const fitBackground = React.useCallback(
(canvas: Canvas, img: FabricImage) => {
const stage = stageRef.current
const boxW = (stage?.clientWidth || MAX_W) - 16
const boxH = (stage?.clientHeight || MAX_H) - 16
const scale = Math.min(boxW / img.width, boxH / img.height)
img.scale(scale)
img.set({ left: 0, top: 0, originX: 'left', originY: 'top' })
// Size the canvas to the *scaled* image so there is no empty margin.
canvas.setDimensions({
width: img.getScaledWidth(),
height: img.getScaledHeight(),
})
canvas.backgroundImage = img
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]) // reset zoom/pan to fit
canvas.calcOffset() // refresh cached element offset for pointer mapping
canvas.requestRenderAll()
const w = Math.round(img.width)
const h = Math.round(img.height)
dimsRef.current = { w, h }
fitScaleRef.current = scale
setDims({ w, h })
setResize({ width: w, height: h, lock: true })
setFitScale(scale)
setZoom(1)
},
[],
)
// Build a preview thumbnail per preset from a downscaled copy of the base
// image, so the Filters strip shows the real effect. Cheap: filters run on a
// 160×120 canvas, not the full-res image.
const regenThumbs = React.useCallback(
(el: CanvasImageSource | undefined, natW: number, natH: number) => {
const fabric = modRef.current
if (!fabric || !el || !natW || !natH) return
const base = document.createElement('canvas')
base.width = THUMB_W
base.height = THUMB_H
const bctx = base.getContext('2d')
if (!bctx) return
// Cover-crop the source into the thumbnail box.
const s = Math.max(THUMB_W / natW, THUMB_H / natH)
const dw = natW * s
const dh = natH * s
bctx.drawImage(el, (THUMB_W - dw) / 2, (THUMB_H - dh) / 2, dw, dh)
const out: Record<string, string> = {}
for (const p of PRESETS) {
const fi = new fabric.FabricImage(base, {
originX: 'left',
originY: 'top',
})
const list = p.build(fabric.filters)
if (list.length) {
fi.filters = list
fi.applyFilters()
}
const c = new fabric.StaticCanvas(document.createElement('canvas'), {
width: THUMB_W,
height: THUMB_H,
enableRetinaScaling: false,
})
c.add(fi)
c.renderAll()
out[p.id] = c.toDataURL({ format: 'jpeg', quality: 0.8, multiplier: 1 })
c.dispose()
}
setThumbs(out)
},
[],
)
const pushHistory = React.useCallback(() => {
const canvas = fabricRef.current
if (!canvas || restoringRef.current) return
const h = historyRef.current
const json = JSON.stringify(canvas.toJSON())
const top = h.stack[h.index]
if (
top &&
top.json === json &&
top.width === canvas.width &&
top.height === canvas.height
)
return
const snap: Snapshot = {
json,
width: canvas.width,
height: canvas.height,
dims: dimsRef.current ?? {
w: Math.round(canvas.width),
h: Math.round(canvas.height),
},
fitScale: fitScaleRef.current,
}
h.stack = h.stack.slice(0, h.index + 1)
h.stack.push(snap)
h.index = h.stack.length - 1
syncHistoryFlags()
}, [syncHistoryFlags])
const restore = (snap: Snapshot) => {
const canvas = fabricRef.current
if (!canvas) return
restoringRef.current = true
setSel(null)
// The live mosaic layer is replaced by the deserialized image; drop the refs
// so the next stroke rebuilds a fresh layer (and pixel source) on top.
const m = mosaicRef.current
m.layer = null
m.layerCanvas = null
m.pixSrc = null
void canvas.loadFromJSON(snap.json).then(() => {
// toJSON omits canvas size — restore it (and the derived state) so a
// cropped frame round-trips with objects aligned.
canvas.setDimensions({ width: snap.width, height: snap.height })
canvas.setViewportTransform([1, 0, 0, 1, 0, 0])
dimsRef.current = snap.dims
fitScaleRef.current = snap.fitScale
setDims(snap.dims)
setResize((r) => ({
width: snap.dims.w,
height: snap.dims.h,
lock: r.lock,
}))
setFitScale(snap.fitScale)
setZoom(1)
canvas.requestRenderAll()
restoringRef.current = false
syncHistoryFlags()
layersSyncRef.current()
})
}
const undo = () => {
const h = historyRef.current
if (h.index <= 0) return
h.index -= 1
restore(h.stack[h.index])
}
const redo = () => {
const h = historyRef.current
if (h.index >= h.stack.length - 1) return
h.index += 1
restore(h.stack[h.index])
}
undoRef.current = undo
redoRef.current = redo
// --- zoom ----------------------------------------------------------------
// Zoom via the canvas viewportTransform (like filerobot scales its stage), so
// the image can be panned when zoomed in. The bitmap and pointer mapping are
// handled by fabric, so drawing stays aligned at any zoom. `at` is the
// viewport point to keep fixed (the pointer for wheel, center for buttons).
const applyZoom = React.useCallback(
(z: number, at?: { x: number; y: number }) => {
const fabric = modRef.current
const canvas = fabricRef.current
if (!fabric || !canvas) return
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z))
if (next <= 1) {
canvas.setViewportTransform([1, 0, 0, 1, 0, 0])
} else {
const pt = at ?? { x: canvas.width / 2, y: canvas.height / 2 }
canvas.zoomToPoint(new fabric.Point(pt.x, pt.y), next)
clampViewport(canvas)
}
canvas.defaultCursor =
next > 1 && !canvas.isDrawingMode && !mosaicRef.current.on
? 'grab'
: mosaicRef.current.on
? 'crosshair'
: 'default'
// Drag is reserved for panning once zoomed in, so suppress rubber-band
// selection (single-object click selection still works).
canvas.selection = next <= 1 && !mosaicRef.current.on
setZoom(next)
selSyncRef.current() // keep the selection toolbar anchored
},
[],
)
const zoomBy = (factor: number) => applyZoom(zoom * factor)
// Reset every edit back to the freshly-loaded original (history frame 0).
const reset = () => {
const h = historyRef.current
if (h.index <= 0) return
h.index = 0
setAdjust(NEUTRAL)
setPreset('original')
applyZoom(1)
restore(h.stack[0])
}
// --- mount ---------------------------------------------------------------
React.useEffect(() => {
let cancelled = false
let canvas: Canvas | null = null
let cleanupKeys: (() => void) | null = null
void (async () => {
const fabric = await import('fabric')
if (cancelled || !canvasElRef.current) return
modRef.current = fabric
// Match object selection styling to the image-crop primitive: teal border
// with round, white-bordered handles. Applies to every object created.
fabric.InteractiveFabricObject.ownDefaults = {
...fabric.InteractiveFabricObject.ownDefaults,
borderColor: '#2dd4bf',
cornerColor: '#2dd4bf',
cornerStrokeColor: '#ffffff',
cornerStyle: 'circle',
transparentCorners: false,
cornerSize: 12,
borderScaleFactor: 1.5,
}
// Persist user-given layer names: customProperties are serialized by
// toObject (and restored by loadFromJSON), so names survive undo/redo.
fabric.FabricObject.customProperties = ['layerName']
canvas = new fabric.Canvas(canvasElRef.current, {
backgroundColor: '#fff',
enableRetinaScaling: false,
preserveObjectStacking: true,
selectionColor: 'rgba(45,212,191,0.15)',
selectionBorderColor: '#2dd4bf',
selectionLineWidth: 1.5,
})
fabricRef.current = canvas
const cv = canvas
const img = await fabric.FabricImage.fromURL(imageUrl, {
crossOrigin: 'anonymous',
})
if (cancelled) return
fitBackground(cv, img)
regenThumbs(img.getElement(), img.width, img.height)
// Any object mutation snapshots history — except the live mosaic layer,
// whose add/paint is committed as a single frame on mouse:up.
const onChange = (e?: { target?: FabricObject }) => {
if (e?.target && e.target === mosaicRef.current.layer) return
// A drag-created object commits a single frame on mouse:up, so suppress
// the intermediate add / modify churn while it is being drawn.
if (creatingRef.current) return
pushHistory()
}
cv.on('object:added', onChange)
cv.on('object:removed', onChange)
cv.on('object:modified', onChange)
cv.on('path:created', () => pushHistory())
// Floating selection toolbar: anchor it to the active object's bounding
// box (in stage px) and mirror the object's colour into the picker.
const syncSelection = () => {
const obj = cv.getActiveObject()
if (!obj || mosaicRef.current.on) {
setSel(null)
return
}
const isText =
obj instanceof fabric.IText ||
obj instanceof fabric.Textbox ||
obj instanceof fabric.FabricText
const t = obj as {
strokeWidth?: number
stroke?: unknown
fill?: unknown
fontFamily?: string
fontSize?: number
fontWeight?: string | number
fontStyle?: string
textAlign?: string
opacity?: number
}
const hasStroke =
!isText && typeof t.strokeWidth === 'number' && !!t.stroke
const align = ALIGNS.includes(t.textAlign as (typeof ALIGNS)[number])
? (t.textAlign as (typeof ALIGNS)[number])
: 'left'
setSel({
kind: isText ? 'text' : hasStroke ? 'shape' : 'other',
showColor: isText || hasStroke,
strokeWidth: t.strokeWidth ?? 1,
fontFamily: t.fontFamily ?? 'sans-serif',
fontSize: Math.round(t.fontSize ?? 28),
bold: t.fontWeight === 'bold' || Number(t.fontWeight) >= 700,
italic: t.fontStyle === 'italic',
align,
opacity: t.opacity ?? 1,
})
const col = isText ? t.fill : t.stroke
if (typeof col === 'string') setColor(col)
}
cv.on('selection:created', syncSelection)
cv.on('selection:updated', syncSelection)
cv.on('selection:cleared', () => setSel(null))
cv.on('object:modified', syncSelection)
selSyncRef.current = syncSelection
// Layers panel: rebuild the top-first object list on any structural or
// selection change.
const idFor = (obj: FabricObject) => {
let id = layerIdsRef.current.get(obj)
if (!id) {
id = `layer-${(layerSeqRef.current += 1)}`
layerIdsRef.current.set(obj, id)
}
return id
}
const rebuildLayers = () => {
const objs = cv.getObjects()
const activeObj = cv.getActiveObject()
const list: Layer[] = []
for (let i = objs.length - 1; i >= 0; i -= 1) {
const o = objs[i]
const meta = layerMeta(o, fabric, mosaicRef.current.layer)
const name = (o as FabricObject & { layerName?: string }).layerName
list.push({
id: idFor(o),
label: name || meta.label,
name,
Icon: meta.Icon,
visible: o.visible !== false,
active: o === activeObj,
obj: o,
})
}
setLayers(list)
}
cv.on('object:added', rebuildLayers)
cv.on('object:removed', rebuildLayers)
cv.on('selection:created', rebuildLayers)
cv.on('selection:updated', rebuildLayers)
cv.on('selection:cleared', rebuildLayers)
layersSyncRef.current = rebuildLayers
rebuildLayers()
// Mosaic brush — reveal a pixelated copy of the background through a round
// brush onto a live layer. Source + layer are (re)built lazily on the
// first stroke and after any history load / dimension change nulls them.
const buildPixSrc = () => {
const m = mosaicRef.current
const bg = cv.backgroundImage as FabricImage | undefined
const el = bg?.getElement() as CanvasImageSource | undefined
if (!el) return
const w = cv.width
const h = cv.height
const block = Math.max(2, m.block)
const src = m.pixSrc ?? document.createElement('canvas')
src.width = w
src.height = h
const sctx = src.getContext('2d')
const tiny = document.createElement('canvas')
tiny.width = Math.max(1, Math.round(w / block))
tiny.height = Math.max(1, Math.round(h / block))
const tctx = tiny.getContext('2d')
if (!sctx || !tctx) return
tctx.drawImage(el, 0, 0, tiny.width, tiny.height)
sctx.imageSmoothingEnabled = false
sctx.clearRect(0, 0, w, h)
sctx.drawImage(tiny, 0, 0, tiny.width, tiny.height, 0, 0, w, h)
m.pixSrc = src
}
const ensureLayer = () => {
const fabric = modRef.current
const m = mosaicRef.current
if (!fabric) return
const lc = m.layerCanvas ?? document.createElement('canvas')
lc.width = cv.width
lc.height = cv.height
m.layerCanvas = lc
// originX/originY must be top-left: fabric v7 defaults them to center,
// which would offset the painted layer by half the canvas.
const layer = new fabric.FabricImage(lc, {
left: 0,
top: 0,
originX: 'left',
originY: 'top',
selectable: false,
evented: false,
})
m.layer = layer
cv.add(layer)
}
const stamp = (pt: { x: number; y: number }) => {
const m = mosaicRef.current
if (!m.pixSrc) buildPixSrc()
if (!m.layer) ensureLayer()
const lc = m.layerCanvas
const pix = m.pixSrc
const layer = m.layer
const ctx = lc?.getContext('2d')
if (!lc || !pix || !layer || !ctx) return
ctx.save()
ctx.beginPath()
ctx.arc(pt.x, pt.y, m.width / 2, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(pix, 0, 0)
ctx.restore()
layer.dirty = true
cv.requestRenderAll()
}
cv.on('mouse:down', (opt) => {
const m = mosaicRef.current
if (!m.on || panRef.current.spaceHeld) return
m.painting = true
stamp(cv.getScenePoint(opt.e))
})
cv.on('mouse:move', (opt) => {
const m = mosaicRef.current
if (!m.on || !m.painting) return
stamp(cv.getScenePoint(opt.e))
})
cv.on('mouse:up', () => {
const m = mosaicRef.current
if (!m.painting) return
m.painting = false
pushHistory()
})
// Tool-create: the Text tool drops a text box where you click; the Shape
// tool drags rect / ellipse / arrow to size at the pointer. Both return to
// Select afterwards. Gated on the active tool so they never fire under
// Select / pan / brush tools. `creatingRef` suppresses history until up.
cv.on('mouse:down', (opt) => {
if (panRef.current.spaceHeld) return
const t = toolRef.current
if (t === 'text') {
const p = cv.getScenePoint(opt.e)
const it = new fabric.IText('Text', {
left: p.x,
top: p.y,
originX: 'left',
originY: 'top',
fontFamily: 'sans-serif',
fontSize: 28,
fill: colorRef.current,
})
cv.add(it)
cv.setActiveObject(it)
it.enterEditing()
it.selectAll()
cv.requestRenderAll()
activateToolRef.current('select')
return
}
if (t !== 'shape') return
const p = cv.getScenePoint(opt.e)
const kind = shapeKindRef.current
const c = colorRef.current
let obj: FabricObject
if (kind === 'rect') {
obj = new fabric.Rect({
left: p.x,
top: p.y,
width: 1,
height: 1,
fill: 'transparent',
stroke: c,
strokeWidth: 3,
originX: 'left',
originY: 'top',
})
} else if (kind === 'ellipse') {
obj = new fabric.Ellipse({
left: p.x,
top: p.y,
rx: 1,
ry: 1,
fill: 'transparent',
stroke: c,
strokeWidth: 3,
originX: 'left',
originY: 'top',
})
} else {
obj = new fabric.Line([p.x, p.y, p.x, p.y], {
stroke: c,
strokeWidth: 4,
})
}
creatingRef.current = { obj, sx: p.x, sy: p.y, kind }
cv.add(obj)
})
cv.on('mouse:move', (opt) => {
const cr = creatingRef.current
if (!cr) return
const p = cv.getScenePoint(opt.e)
const w = p.x - cr.sx
const h = p.y - cr.sy
if (cr.kind === 'rect') {
cr.obj.set({
left: Math.min(cr.sx, p.x),
top: Math.min(cr.sy, p.y),
width: Math.abs(w),
height: Math.abs(h),
})
} else if (cr.kind === 'ellipse') {
cr.obj.set({
left: Math.min(cr.sx, p.x),
top: Math.min(cr.sy, p.y),
rx: Math.abs(w) / 2,
ry: Math.abs(h) / 2,
})
} else {
cr.obj.set({ x2: p.x, y2: p.y })
}
cr.obj.setCoords()
cv.requestRenderAll()
})
cv.on('mouse:up', (opt) => {
const cr = creatingRef.current
if (!cr) return
const p = cv.getScenePoint(opt.e)
const dx = p.x - cr.sx
const dy = p.y - cr.sy
const tiny = Math.abs(dx) < 6 && Math.abs(dy) < 6
if (cr.kind === 'arrow') {
// Replace the temp drag line with a grouped line + head (still under
// the creating guard, so the remove/add pair commits no extra frame).
cv.remove(cr.obj)
const ex = tiny ? cr.sx + 90 : p.x
const ey = tiny ? cr.sy : p.y
const line = new fabric.Line([cr.sx, cr.sy, ex, ey], {
stroke: colorRef.current,
strokeWidth: 4,
})
const head = new fabric.Triangle({
width: 18,
height: 18,
fill: colorRef.current,
left: ex,
top: ey,
angle: (Math.atan2(ey - cr.sy, ex - cr.sx) * 180) / Math.PI + 90,
originX: 'center',
originY: 'center',
})
const grp = new fabric.Group([line, head])
cv.add(grp)
cv.setActiveObject(grp)
} else {
if (tiny) {
if (cr.kind === 'rect') cr.obj.set({ width: 120, height: 80 })
else cr.obj.set({ rx: 60, ry: 40 })
}
cr.obj.setCoords()
cv.setActiveObject(cr.obj)
}
creatingRef.current = null
cv.requestRenderAll()
pushHistory()
activateToolRef.current('select')
})
// Drag-to-pan when zoomed in: hold space anywhere, use the Hand tool, or
// just drag empty canvas in non-creating tools. Object dragging (a hit
// target) and the brushes keep their own drag.
const shouldPan = (opt: { target?: FabricObject }) => {
if (panRef.current.spaceHeld || toolRef.current === 'hand')
return cv.getZoom() > 1
if (cv.getZoom() <= 1) return false
if (toolRef.current === 'text' || toolRef.current === 'shape')
return false
return !cv.isDrawingMode && !mosaicRef.current.on && !opt.target
}
cv.on('mouse:down', (opt) => {
if (!shouldPan(opt)) return
const p = cv.getViewportPoint(opt.e)
panRef.current.panning = true
panRef.current.lastX = p.x
panRef.current.lastY = p.y
cv.selection = false
cv.setCursor('grabbing')
})
cv.on('mouse:move', (opt) => {
if (!panRef.current.panning) return
const p = cv.getViewportPoint(opt.e)
cv.relativePan(
new fabric.Point(
p.x - panRef.current.lastX,
p.y - panRef.current.lastY,
),
)
clampViewport(cv)
panRef.current.lastX = p.x
panRef.current.lastY = p.y
})
cv.on('mouse:up', () => {
if (!panRef.current.panning) return
panRef.current.panning = false
cv.selection = !mosaicRef.current.on && cv.getZoom() <= 1
cv.setCursor(cv.defaultCursor)
})
// Wheel zooms toward the pointer.
cv.on('mouse:wheel', (opt) => {
const e = opt.e
e.preventDefault()
e.stopPropagation()
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
applyZoom(cv.getZoom() * factor, cv.getViewportPoint(e))
})
// Space toggles a pan grab (suppressing the pen while held).
const isTyping = () => {
const el = document.activeElement
const tag = el?.tagName
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(cv.getActiveObject() as { isEditing?: boolean } | null)
?.isEditing === true
)
}
const onKeyDown = (e: KeyboardEvent) => {
// Editing shortcuts (suppressed while typing in a field / IText).
if (!isTyping()) {
const metaKey = e.metaKey || e.ctrlKey
if (metaKey && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault()
if (e.shiftKey) redoRef.current()
else undoRef.current()
return
}
if (metaKey && (e.key === 'y' || e.key === 'Y')) {
e.preventDefault()
redoRef.current()
return
}
if (e.key === 'Delete' || e.key === 'Backspace') {
const obj = cv.getActiveObject()
if (!obj) return
e.preventDefault()
cv.remove(obj)
cv.discardActiveObject()
cv.requestRenderAll()
return
}
if (!metaKey) {
const tk = TOOLS.find((t) => t.hotkey === e.key.toLowerCase())
if (tk) {
e.preventDefault()
activateToolRef.current(tk.value)
return
}
}
}
if (e.code !== 'Space' || e.repeat || isTyping()) return
if (cv.getZoom() <= 1) return
e.preventDefault()
const pan = panRef.current
pan.spaceHeld = true
pan.prevDrawing = cv.isDrawingMode
cv.isDrawingMode = false
cv.defaultCursor = 'grab'
cv.setCursor('grab')
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== 'Space') return
const pan = panRef.current
if (!pan.spaceHeld) return
pan.spaceHeld = false
cv.isDrawingMode = pan.prevDrawing
cv.defaultCursor =
cv.getZoom() > 1 && !cv.isDrawingMode && !mosaicRef.current.on
? 'grab'
: mosaicRef.current.on
? 'crosshair'
: 'default'
cv.setCursor(cv.defaultCursor)
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
cleanupKeys = () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
// Seed the initial snapshot.
historyRef.current = {
stack: [
{
json: JSON.stringify(cv.toJSON()),
width: cv.width,
height: cv.height,
dims: dimsRef.current ?? {
w: Math.round(cv.width),
h: Math.round(cv.height),
},
fitScale: fitScaleRef.current,
},
],
index: 0,
}
syncHistoryFlags()
setReady(true)
})()
return () => {
cancelled = true
cleanupKeys?.()
canvas?.dispose()
fabricRef.current = null
modRef.current = null
const m = mosaicRef.current
m.layer = null
m.layerCanvas = null
m.pixSrc = null
setReady(false)
}
}, [
imageUrl,
pushHistory,
fitBackground,
syncHistoryFlags,
applyZoom,
regenThumbs,
])
// --- adjust / filters ----------------------------------------------------
const applyBackgroundFilters = React.useCallback(
(next: Adjust, nextPreset: string) => {
const fabric = modRef.current
const canvas = fabricRef.current
const img = canvas?.backgroundImage as FabricImage | undefined
if (!fabric || !canvas || !img) return
const f = fabric.filters
const def = PRESETS.find((p) => p.id === nextPreset)
img.filters = [
...(def ? def.build(f) : []),
new f.Brightness({ brightness: next.brightness }),
new f.Contrast({ contrast: next.contrast }),
new f.Saturation({ saturation: next.saturation }),
] as FabricImage['filters']
img.applyFilters()
canvas.requestRenderAll()
},
[],
)
const onAdjust = (key: keyof Adjust, value: number) => {
const next = { ...adjust, [key]: value }
setAdjust(next)
applyBackgroundFilters(next, preset)
}
const onPreset = (id: string) => {
setPreset(id)
applyBackgroundFilters(adjust, id)
pushHistory()
}
// --- annotate ------------------------------------------------------------
const add = (obj: FabricObject) => {
const canvas = fabricRef.current
if (!canvas) return
if (canvas.isDrawingMode) setDraw(false) // adding an object exits the pen
canvas.add(obj)
canvas.setActiveObject(obj)
canvas.requestRenderAll()
}
const center = () => {
const canvas = fabricRef.current
return {
left: (canvas?.width ?? MAX_W) / 2,
top: (canvas?.height ?? MAX_H) / 2,
}
}
const addOverlay = (file: File) => {
const fabric = modRef.current
const canvas = fabricRef.current
if (!fabric || !canvas) return
const url = URL.createObjectURL(file)
void fabric.FabricImage.fromURL(url, { crossOrigin: 'anonymous' }).then(
(img) => {
URL.revokeObjectURL(url)
const scale = Math.min(1, 220 / (img.width ?? 1))
img.set({
...center(),
originX: 'center',
originY: 'center',
scaleX: scale,
scaleY: scale,
})
add(img)
},
)
}
const addWatermarkText = () => {
const fabric = modRef.current
if (!fabric) return
add(
new fabric.IText('Watermark', {
...center(),
originX: 'center',
originY: 'center',
fontFamily: 'sans-serif',
fontSize: 40,
fontWeight: 600,
fill: '#ffffff',
opacity: 0.6,
stroke: 'rgba(0,0,0,0.25)',
strokeWidth: 1,
}),
)
}
// Set props on the active object, re-sync the toolbar, optionally commit a
// history frame (discrete edits commit immediately; sliders commit on release).
const setActiveProp = (patch: Record<string, unknown>, commit = true) => {
const canvas = fabricRef.current
const obj = canvas?.getActiveObject()
if (!canvas || !obj) return
obj.set(patch)
obj.setCoords()
canvas.requestRenderAll()
selSyncRef.current()
if (commit) pushHistory()
}
const changeStrokeWidth = (w: number) =>
setActiveProp({ strokeWidth: w }, false)
const changeOpacity = (o: number) => setActiveProp({ opacity: o }, false)
const changeFontSize = (n: number) =>
setActiveProp({ fontSize: Math.max(1, Math.round(n || 0)) })
const changeFontFamily = (f: string) => setActiveProp({ fontFamily: f })
const toggleBold = () =>
setActiveProp({ fontWeight: sel?.bold ? 'normal' : 'bold' })
const toggleItalic = () =>
setActiveProp({ fontStyle: sel?.italic ? 'normal' : 'italic' })
const cycleAlign = () => {
const next =
ALIGNS[(ALIGNS.indexOf(sel?.align ?? 'left') + 1) % ALIGNS.length]
setActiveProp({ textAlign: next })
}
// --- layers --------------------------------------------------------------
const layerSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
)
const selectLayer = (obj: FabricObject) => {
const canvas = fabricRef.current
if (!canvas || obj === mosaicRef.current.layer || obj.selectable === false)
return
canvas.setActiveObject(obj)
canvas.requestRenderAll()
}
const toggleLayerVisible = (obj: FabricObject) => {
const canvas = fabricRef.current
if (!canvas) return
obj.visible = !obj.visible
canvas.requestRenderAll()
layersSyncRef.current()
pushHistory()
}
const deleteLayer = (obj: FabricObject) => {
const canvas = fabricRef.current
if (!canvas) return
if (canvas.getActiveObject() === obj) canvas.discardActiveObject()
canvas.remove(obj) // fires object:removed → history + rebuild
canvas.requestRenderAll()
}
const duplicateLayer = (obj: FabricObject) => {
const canvas = fabricRef.current
if (!canvas) return
void obj.clone().then((c) => {
c.set({ left: (c.left ?? 0) + 16, top: (c.top ?? 0) + 16 })
canvas.add(c) // fires object:added → history + rebuild
canvas.setActiveObject(c)
canvas.requestRenderAll()
})
}
const moveLayer = (obj: FabricObject, dir: 'forward' | 'back') => {
const canvas = fabricRef.current
if (!canvas) return
if (dir === 'forward') canvas.bringObjectForward(obj)
else canvas.sendObjectBackwards(obj)
canvas.requestRenderAll()
layersSyncRef.current()
pushHistory()
}
const renameLayer = (obj: FabricObject, name: string) => {
const trimmed = name.trim()
const o = obj as FabricObject & { layerName?: string }
if ((o.layerName ?? '') === trimmed) return
// Empty name reverts to the type label.
o.layerName = trimmed || undefined
layersSyncRef.current()
pushHistory()
}
// `layers` is top-first; fabric stacking is bottom-first.
const reorderLayers = (e: DragEndEvent) => {
const canvas = fabricRef.current
if (!canvas || !e.over || e.active.id === e.over.id) return
const from = layers.findIndex((l) => l.id === e.active.id)
const to = layers.findIndex((l) => l.id === e.over?.id)
if (from < 0 || to < 0) return
canvas.moveObjectTo(layers[from].obj, canvas.getObjects().length - 1 - to)
canvas.requestRenderAll()
layersSyncRef.current()
pushHistory()
}
// --- draw ----------------------------------------------------------------
const setDraw = (on: boolean) => {
const fabric = modRef.current
const canvas = fabricRef.current
if (!fabric || !canvas) return
canvas.isDrawingMode = on
if (on) {
const brush = new fabric.PencilBrush(canvas)
brush.color = color
brush.width = penWidth
canvas.freeDrawingBrush = brush
}
}
// Color applies live: to the pen brush and to the selected object (text fill /
// shape stroke), not only to objects created afterwards.
const changeColor = (c: string) => {
setColor(c)
const fabric = modRef.current
const canvas = fabricRef.current
if (!fabric || !canvas) return
if (canvas.freeDrawingBrush) canvas.freeDrawingBrush.color = c
const obj = canvas.getActiveObject()
if (obj) {
const isText =
obj instanceof fabric.IText ||
obj instanceof fabric.Textbox ||
obj instanceof fabric.FabricText
obj.set(isText ? 'fill' : 'stroke', c)
canvas.requestRenderAll()
pushHistory()
}
}
// --- mosaic --------------------------------------------------------------
// Toggle the mosaic brush. While on, drag over the image to pixelate; object
// selection is suppressed so strokes don't grab annotations. The actual paint
// happens in the canvas mouse handlers wired at mount.
const toggleMosaic = (on: boolean) => {
const canvas = fabricRef.current
if (!canvas) return
if (on) {
setDraw(false)
canvas.discardActiveObject()
setSel(null)
}
mosaicRef.current.on = on
canvas.isDrawingMode = false
canvas.selection = !on && canvas.getZoom() <= 1
canvas.skipTargetFind = on
canvas.defaultCursor = on
? 'crosshair'
: canvas.getZoom() > 1
? 'grab'
: 'default'
}
// --- transform -----------------------------------------------------------
const flip = (axis: 'x' | 'y') => {
const canvas = fabricRef.current
const img = canvas?.backgroundImage as FabricImage | undefined
if (!canvas || !img) return
if (axis === 'x') img.flipX = !img.flipX
else img.flipY = !img.flipY
canvas.requestRenderAll()
pushHistory()
}
// Bake a 90° turn: redraw the base element rotated, swap canvas dims, refit.
const rotate = (dir: 'cw' | 'ccw') => {
const fabric = modRef.current
const canvas = fabricRef.current
const img = canvas?.backgroundImage as FabricImage | undefined
const el = img?.getElement() as HTMLImageElement | undefined
if (!fabric || !canvas || !img || !el) return
const w = el.naturalWidth
const h = el.naturalHeight
const off = document.createElement('canvas')
off.width = h
off.height = w
const ctx = off.getContext('2d')
if (!ctx) return
ctx.translate(h / 2, w / 2)
ctx.rotate(((dir === 'cw' ? 1 : -1) * Math.PI) / 2)
ctx.drawImage(el, -w / 2, -h / 2)
void fabric.FabricImage.fromURL(off.toDataURL()).then((rotated) => {
fitBackground(canvas, rotated)
regenThumbs(rotated.getElement(), rotated.width, rotated.height)
applyBackgroundFilters(adjust, preset)
mosaicRef.current.pixSrc = null // canvas dims changed
pushHistory()
})
}
// --- crop ----------------------------------------------------------------
// Reuse the ikui `image-crop` primitive: snapshot the canvas into an <img>,
// overlay ImageCrop for the selection, then on apply re-frame the fabric
// canvas to the chosen region (shift the bg + every object, resize).
const seedCrop = (a: number | undefined, w: number, h: number): PercentCrop =>
centerCrop(
a
? makeAspectCrop({ unit: '%', width: 80 }, a, w, h)
: { unit: '%', x: 0, y: 0, width: 80, height: 80 },
w,
h,
)
const enterCrop = () => {
const canvas = fabricRef.current
if (!canvas) return
applyZoom(1) // crop math runs in canvas coords
canvas.discardActiveObject()
canvas.setViewportTransform([1, 0, 0, 1, 0, 0])
const url = canvas.toDataURL({ format: 'png', multiplier: 1 })
canvas.requestRenderAll()
setCropSnapshot(url)
setCrop(seedCrop(cropAspect, canvas.width, canvas.height))
}
const exitCrop = () => {
setCropSnapshot(null)
setCrop(undefined)
}
const reseedCrop = (a: number | undefined) => {
const canvas = fabricRef.current
setCropAspect(a)
if (canvas) setCrop(seedCrop(a, canvas.width, canvas.height))
}
const applyCrop = () => {
const canvas = fabricRef.current
if (!canvas || !crop) return
const pc = convertToPixelCrop(crop, canvas.width, canvas.height)
const left = Math.max(0, pc.x)
const top = Math.max(0, pc.y)
const cw = Math.min(canvas.width - left, pc.width)
const ch = Math.min(canvas.height - top, pc.height)
if (cw < 8 || ch < 8) return
// Shift the background + every object so the crop's top-left becomes (0,0).
const bg = canvas.backgroundImage as FabricImage | undefined
if (bg) {
bg.set({ left: (bg.left ?? 0) - left, top: (bg.top ?? 0) - top })
bg.setCoords()
}
for (const o of canvas.getObjects()) {
o.set({ left: (o.left ?? 0) - left, top: (o.top ?? 0) - top })
o.setCoords()
}
canvas.setDimensions({ width: cw, height: ch })
canvas.setViewportTransform([1, 0, 0, 1, 0, 0])
mosaicRef.current.pixSrc = null
const s = fitScale || 1
const nw = Math.round(cw / s)
const nh = Math.round(ch / s)
dimsRef.current = { w: nw, h: nh }
setDims({ w: nw, h: nh })
setResize({ width: nw, height: nh, lock: true })
setZoom(1)
canvas.requestRenderAll()
exitCrop()
pushHistory()
}
// --- resize --------------------------------------------------------------
const onResize = (dim: 'width' | 'height', value: number) => {
const v = Math.max(1, Math.round(value || 0))
setResize((r) => {
if (!r.lock || !dims) return { ...r, [dim]: v }
const aspect = dims.w / dims.h
return dim === 'width'
? { ...r, width: v, height: Math.max(1, Math.round(v / aspect)) }
: { ...r, height: v, width: Math.max(1, Math.round(v * aspect)) }
})
}
// --- io ------------------------------------------------------------------
const replaceImage = (file: File) => {
const fabric = modRef.current
const canvas = fabricRef.current
if (!fabric || !canvas) return
const url = URL.createObjectURL(file)
void fabric.FabricImage.fromURL(url, { crossOrigin: 'anonymous' }).then(
(img) => {
URL.revokeObjectURL(url)
canvas.remove(...canvas.getObjects())
const m = mosaicRef.current
m.layer = null
m.layerCanvas = null
m.pixSrc = null
setAdjust(NEUTRAL)
setPreset('original')
fitBackground(canvas, img)
regenThumbs(img.getElement(), img.width, img.height)
historyRef.current = {
stack: [
{
json: JSON.stringify(canvas.toJSON()),
width: canvas.width,
height: canvas.height,
dims: dimsRef.current ?? {
w: Math.round(canvas.width),
h: Math.round(canvas.height),
},
fitScale: fitScaleRef.current,
},
],
index: 0,
}
syncHistoryFlags()
},
)
}
const exportAs = async (format: 'png' | 'jpeg') => {
const canvas = fabricRef.current
if (!canvas) return
canvas.discardActiveObject()
// Render the whole image at full (source) resolution, ignoring zoom/pan.
const vpt = [...canvas.viewportTransform] as typeof canvas.viewportTransform
canvas.setViewportTransform([1, 0, 0, 1, 0, 0])
const fullUrl = canvas.toDataURL({
format,
quality: 0.95,
multiplier: dims ? dims.w / canvas.width : 1,
})
canvas.setViewportTransform(vpt)
canvas.requestRenderAll()
// Scale to the chosen output size (Resize tool).
const tw = resize.width || dims?.w || canvas.width
const th = resize.height || dims?.h || canvas.height
const out = document.createElement('canvas')
out.width = tw
out.height = th
const octx = out.getContext('2d')
if (!octx) return
const im = new Image()
im.src = fullUrl
await im.decode()
octx.drawImage(im, 0, 0, tw, th)
const mime = format === 'jpeg' ? 'image/jpeg' : 'image/png'
const url = out.toDataURL(mime, 0.95)
const blob = await (await fetch(url)).blob()
const ext = format === 'jpeg' ? 'jpg' : 'png'
const file = new File([blob], `image-edited.${ext}`, { type: blob.type })
onExport?.(file)
const a = document.createElement('a')
a.href = url
a.download = file.name
a.click()
}
// Stroke/fill color — only shown by Annotate (objects + pen).
const colorPicker = (
<label className="border-input flex items-center gap-2 rounded-md border px-2 py-1 text-sm">
<span className="text-muted-foreground">Color</span>
<input
type="color"
value={color}
onChange={(e) => changeColor(e.target.value)}
className="size-5 cursor-pointer rounded border-0 bg-transparent p-0"
aria-label="Stroke color"
/>
</label>
)
// Switch the active tool: tear down the tool being left (pen / mosaic / crop),
// then arm the one being entered (pointer mode + cursor). One home for every
// mode transition (replaces the old per-tab side effects).
const activateTool = (next: Tool) => {
if (!next || next === tool) return
const canvas = fabricRef.current
if (tool === 'draw') setDraw(false)
if (tool === 'redact') toggleMosaic(false)
if (tool === 'crop' && cropSnapshot) exitCrop()
setTool(next)
if (!canvas) return
if (next === 'draw') {
setDraw(true)
return
}
if (next === 'redact') {
toggleMosaic(true)
return
}
if (next === 'crop') {
enterCrop()
return
}
// select / text / shape / hand: configure the pointer directly. text + shape
// suppress hit-testing so a press starts a create instead of grabbing an
// existing object.
canvas.isDrawingMode = false
const create = next === 'text' || next === 'shape'
if (create || next === 'hand') canvas.discardActiveObject()
canvas.skipTargetFind = create
canvas.selection = next === 'select' && canvas.getZoom() <= 1
canvas.defaultCursor =
next === 'text'
? 'text'
: next === 'shape'
? 'crosshair'
: next === 'hand'
? 'grab'
: canvas.getZoom() > 1
? 'grab'
: 'default'
canvas.requestRenderAll()
}
activateToolRef.current = activateTool
// Layers panel — top-first, drag to reorder (dnd-kit), select / hide / delete.
const layersPanel = (
<div className="flex h-full flex-col">
<div className="text-muted-foreground flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium">
<LayersIcon className="size-3.5" /> Layers
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-2">
{layers.length === 0 ? (
<p className="text-muted-foreground px-1 py-2 text-xs">
No layers yet — add text, shapes, or drawings.
</p>
) : (
<DndContext
sensors={layerSensors}
collisionDetection={closestCenter}
onDragEnd={reorderLayers}
>
<SortableContext
items={layers.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
{layers.map((l) => (
<LayerRow
key={l.id}
layer={l}
onSelect={() => selectLayer(l.obj)}
onToggleVisible={() => toggleLayerVisible(l.obj)}
onRename={(name) => renameLayer(l.obj, name)}
onDuplicate={() => duplicateLayer(l.obj)}
onForward={() => moveLayer(l.obj, 'forward')}
onBack={() => moveLayer(l.obj, 'back')}
onDelete={() => deleteLayer(l.obj)}
/>
))}
</SortableContext>
</DndContext>
)}
</div>
</div>
)
// Inspector — Object section (active object's props) shown when something is
// selected; otherwise the Canvas section (whole-image filters / adjust /
// transform). One home for object actions — no more duplicate entry points.
const objectSection = sel && (
<FieldSet className="gap-3">
<FieldLegend variant="label" className="mb-0 text-xs">
{sel.kind === 'text'
? 'Text'
: sel.kind === 'shape'
? 'Shape'
: 'Object'}
</FieldLegend>
{sel.showColor && (
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Color</FieldLabel>
<input
type="color"
value={color}
onChange={(e) => changeColor(e.target.value)}
className="size-7 cursor-pointer rounded border-0 bg-transparent p-0"
aria-label="Color"
/>
</Field>
)}
{sel.kind === 'text' && (
<>
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Font</FieldLabel>
<Select
value={sel.fontFamily}
onValueChange={(v) => changeFontFamily(v as string)}
>
<SelectTrigger
size="sm"
className="w-40"
aria-label="Font family"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONTS.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Size</FieldLabel>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
value={sel.fontSize || ''}
onChange={(e) => changeFontSize(e.target.valueAsNumber)}
className="border-input h-7 w-16 rounded-md border bg-transparent px-1.5 text-sm tabular-nums outline-none"
aria-label="Font size"
/>
<Button
size="icon-sm"
variant={sel.bold ? 'secondary' : 'ghost'}
aria-label="Bold"
title="Bold"
onClick={toggleBold}
>
<Bold />
</Button>
<Button
size="icon-sm"
variant={sel.italic ? 'secondary' : 'ghost'}
aria-label="Italic"
title="Italic"
onClick={toggleItalic}
>
<Italic />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label={`Align ${sel.align}`}
title={`Align: ${sel.align}`}
onClick={cycleAlign}
>
{React.createElement(ALIGN_ICON[sel.align])}
</Button>
</div>
</Field>
</>
)}
{sel.kind === 'shape' && (
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Stroke</FieldLabel>
<Slider
value={[sel.strokeWidth]}
min={1}
max={40}
onValueChange={(v) => changeStrokeWidth((v as number[])[0])}
onValueCommitted={pushHistory}
aria-label="Stroke width"
/>
</Field>
)}
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Opacity</FieldLabel>
<Slider
value={[Math.round(sel.opacity * 100)]}
min={10}
max={100}
onValueChange={(v) => changeOpacity((v as number[])[0] / 100)}
onValueCommitted={pushHistory}
aria-label="Opacity"
/>
</Field>
<FieldDescription className="text-xs">
Duplicate, reorder, and delete from the layer's ··· menu.
</FieldDescription>
</FieldSet>
)
const canvasSection = (
<FieldSet className="gap-3">
<FieldLegend variant="label" className="mb-0 text-xs">
Whole image
</FieldLegend>
<ToggleGroup
value={[preset]}
onValueChange={(v) => onPreset(v[0] ?? preset)}
className="w-full flex-wrap gap-3 pb-1"
>
{PRESETS.map((p) => {
const active = preset === p.id
return (
<ToggleGroupItem
key={p.id}
value={p.id}
aria-label={p.label}
className="h-auto shrink-0 flex-col gap-1.5 bg-transparent p-0 hover:bg-transparent aria-pressed:bg-transparent"
>
<span
className={`block overflow-hidden rounded-md border-2 transition-colors ${
active
? 'border-primary'
: 'border-transparent group-hover/toggle:border-border'
}`}
>
<span
className="bg-muted block h-15 w-20 bg-cover bg-center"
style={
thumbs[p.id]
? { backgroundImage: `url(${thumbs[p.id]})` }
: undefined
}
/>
</span>
<span
className={`text-xs ${
active ? 'text-primary font-medium' : 'text-muted-foreground'
}`}
>
{p.label}
</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
{(['brightness', 'contrast', 'saturation'] as const).map((k) => (
<Field key={k} orientation="horizontal">
<FieldLabel className={cn(FIELD_LABEL, 'capitalize')}>{k}</FieldLabel>
<Slider
value={[Math.round(adjust[k] * 100)]}
min={-100}
max={100}
onValueChange={(v) => onAdjust(k, (v as number[])[0] / 100)}
onValueCommitted={pushHistory}
/>
</Field>
))}
<div className="flex flex-wrap gap-1.5">
<Button
size="icon-sm"
variant="outline"
aria-label="Rotate left"
title="Rotate left"
onClick={() => rotate('ccw')}
>
<RotateCcw />
</Button>
<Button
size="icon-sm"
variant="outline"
aria-label="Rotate right"
title="Rotate right"
onClick={() => rotate('cw')}
>
<RotateCw />
</Button>
<Button
size="icon-sm"
variant="outline"
aria-label="Flip horizontal"
title="Flip horizontal"
onClick={() => flip('x')}
>
<FlipHorizontal2 />
</Button>
<Button
size="icon-sm"
variant="outline"
aria-label="Flip vertical"
title="Flip vertical"
onClick={() => flip('y')}
>
<FlipVertical2 />
</Button>
</div>
</FieldSet>
)
const toolOptions = (() => {
if (tool === 'text')
return (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
Click on the image to place text.
</p>
<div className="flex items-center gap-2">
{colorPicker}
<Button size="sm" variant="outline" onClick={addWatermarkText}>
<Stamp /> Watermark
</Button>
</div>
</div>
)
if (tool === 'shape')
return (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
Drag on the image to draw.
</p>
<div className="flex flex-wrap items-center gap-2">
<ToggleGroup
value={[shapeKind]}
onValueChange={(v) =>
setShapeKind((v[0] as ShapeKind) ?? shapeKind)
}
variant="outline"
size="sm"
>
{SHAPE_KINDS.map((s) => (
<ToggleGroupItem
key={s.value}
value={s.value}
aria-label={s.label}
>
<s.Icon /> {s.label}
</ToggleGroupItem>
))}
</ToggleGroup>
{colorPicker}
</div>
</div>
)
if (tool === 'draw')
return (
<div className="flex flex-col gap-3">
{colorPicker}
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Size</FieldLabel>
<Slider
value={[penWidth]}
min={1}
max={40}
onValueChange={(v) => {
const n = (v as number[])[0]
setPenWidth(n)
const c = fabricRef.current
if (c?.freeDrawingBrush) c.freeDrawingBrush.width = n
}}
/>
</Field>
</div>
)
if (tool === 'redact')
return (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-xs">
Drag over the image to pixelate.
</p>
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Size</FieldLabel>
<Slider
value={[mosaicWidth]}
min={8}
max={80}
onValueChange={(v) => {
const n = (v as number[])[0]
setMosaicWidth(n)
mosaicRef.current.width = n
}}
/>
</Field>
<Field orientation="horizontal">
<FieldLabel className={FIELD_LABEL}>Block</FieldLabel>
<Slider
value={[mosaicBlock]}
min={4}
max={40}
onValueChange={(v) => {
const n = (v as number[])[0]
setMosaicBlock(n)
mosaicRef.current.block = n
mosaicRef.current.pixSrc = null
}}
/>
</Field>
</div>
)
if (tool === 'crop')
return cropSnapshot ? (
<div className="flex flex-col gap-2">
<ToggleGroup
variant="outline"
size="sm"
className="flex-wrap"
value={[
CROP_ASPECTS.find((a) => a.value === cropAspect)?.label ?? 'Free',
]}
onValueChange={(v) => {
const found = CROP_ASPECTS.find((a) => a.label === v[0])
if (found) reseedCrop(found.value)
}}
>
{CROP_ASPECTS.map((a) => (
<ToggleGroupItem key={a.label} value={a.label}>
{a.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => activateTool('select')}
>
Cancel
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => {
applyCrop()
activateTool('select')
}}
>
<Check /> Apply
</Button>
</div>
</div>
) : (
<Button size="sm" variant="outline" onClick={enterCrop}>
<CropIcon /> Start crop
</Button>
)
return null
})()
const inspector = (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-2">
{toolOptions}
{sel
? objectSection
: tool === 'select' || tool === 'hand'
? canvasSection
: null}
</div>
)
return (
<Card className="w-full">
<CardContent className="pt-(--card-spacing)">
{/* Left tool rail + right stage. */}
<div className="flex flex-col gap-4 sm:flex-row">
{/* Left: pointer tools as a vertical toggle rail (single-select). */}
<ToggleGroup
value={[tool]}
onValueChange={(v) => activateTool((v[0] as Tool) ?? tool)}
orientation="vertical"
variant="outline"
className="w-full shrink-0 sm:w-28"
>
{TOOLS.map((t) => (
<ToggleGroupItem
key={t.value}
value={t.value}
aria-label={t.label}
title={`${t.label} (${t.hotkey.toUpperCase()})`}
className="h-auto flex-1 flex-col justify-center gap-1.5 py-2.5 text-xs aria-pressed:font-medium sm:flex-none [&_svg:not([class*='size-'])]:size-5"
>
<t.Icon />
{t.label}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* Right: top bar (dims/zoom + history), the contextual inspector,
then the canvas stage. */}
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Top bar: image dimensions + zoom on the left, history actions
(reset / undo / redo) on the right. */}
<div className="flex flex-wrap items-center gap-2">
<div className="text-muted-foreground flex items-center gap-1 text-sm">
<span className="tabular-nums">
{dims ? `${dims.w} × ${dims.h} px` : '—'}
</span>
<span className="bg-border mx-1 h-4 w-px" />
<Button
size="icon-sm"
variant="ghost"
aria-label="Zoom out"
disabled={!ready || zoom <= ZOOM_MIN}
onClick={() => zoomBy(1 / ZOOM_STEP)}
>
<Minus />
</Button>
<span className="text-foreground w-12 text-center tabular-nums">
{Math.round(fitScale * zoom * 100)}%
</span>
<Button
size="icon-sm"
variant="ghost"
aria-label="Zoom in"
disabled={!ready || zoom >= ZOOM_MAX}
onClick={() => zoomBy(ZOOM_STEP)}
>
<Plus />
</Button>
</div>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant="outline"
aria-label="Reset"
title="Reset"
disabled={!canUndo}
onClick={() => setResetOpen(true)}
>
<History />
</Button>
<Button
size="sm"
variant="outline"
aria-label="Undo"
disabled={!canUndo}
onClick={undo}
>
<Undo2 />
</Button>
<Button
size="sm"
variant="outline"
aria-label="Redo"
disabled={!canRedo}
onClick={redo}
>
<Redo2 />
</Button>
</div>
</div>
{/* Work area: canvas | resizable right panel (options + layers). */}
<ResizablePanelGroup
orientation="horizontal"
className="h-[420px] overflow-hidden rounded-lg border"
>
<ResizablePanel
defaultSize="62%"
minSize="45%"
className="min-w-0"
>
{/* Canvas stage — the image fills it (contain). */}
<div
ref={stageRef}
className="bg-muted/40 relative flex h-full items-center justify-center overflow-hidden p-2"
>
<canvas
ref={canvasElRef}
className="block rounded shadow-sm"
/>
{/* Crop overlay — the image-crop primitive over a canvas snapshot. */}
{cropSnapshot && (
<div className="absolute inset-0 flex items-center justify-center p-2">
<ImageCrop
crop={crop}
aspect={cropAspect}
ruleOfThirds
minWidth={20}
minHeight={20}
onChange={(_pixel, percent) => setCrop(percent)}
className="max-h-full"
>
{/* biome-ignore lint/performance/noImgElement: registry component, no next/image */}
<img
src={cropSnapshot}
alt="Crop source"
className="max-h-[404px] rounded"
/>
</ImageCrop>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize="38%"
minSize="26%"
maxSize="55%"
className="min-w-0"
>
<ResizablePanelGroup orientation="vertical">
<ResizablePanel
defaultSize="48%"
minSize="20%"
className="min-h-0"
>
{inspector}
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize="52%"
minSize="20%"
className="min-h-0"
>
{layersPanel}
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</CardContent>
<CardFooter>
<Button render={<label />} nativeButton={false} variant="outline">
<Upload /> Load
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) replaceImage(f)
e.target.value = ''
}}
/>
</Button>
<Button render={<label />} nativeButton={false} variant="outline">
<ImageUp /> Image
<input
ref={overlayInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) addOverlay(f)
e.target.value = ''
}}
/>
</Button>
{/* Export — output size (the former Resize tool) folds into this popover. */}
<Popover open={exportOpen} onOpenChange={setExportOpen}>
<PopoverTrigger
render={
<Button className="ml-auto" disabled={!ready}>
<Download /> Export
</Button>
}
nativeButton={false}
/>
<PopoverContent side="top" className="w-64">
<div className="flex flex-col gap-3">
<div className="text-sm font-medium">Export</div>
<div className="flex items-center gap-2">
<label className="border-input flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm">
<span className="text-muted-foreground text-xs">W</span>
<input
type="number"
min={1}
value={resize.width || ''}
onChange={(e) => onResize('width', e.target.valueAsNumber)}
className="w-14 bg-transparent tabular-nums outline-none"
aria-label="Output width"
/>
</label>
<label className="border-input flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm">
<span className="text-muted-foreground text-xs">H</span>
<input
type="number"
min={1}
value={resize.height || ''}
onChange={(e) => onResize('height', e.target.valueAsNumber)}
className="w-14 bg-transparent tabular-nums outline-none"
aria-label="Output height"
/>
</label>
<Button
size="icon-sm"
variant="outline"
aria-label="Lock aspect ratio"
title="Lock aspect ratio"
onClick={() => setResize((r) => ({ ...r, lock: !r.lock }))}
>
{resize.lock ? <Lock /> : <LockOpen />}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
disabled={!ready}
onClick={() => {
void exportAs('jpeg')
setExportOpen(false)
}}
>
<Code2 /> JPEG
</Button>
<Button
className="flex-1"
disabled={!ready}
onClick={() => {
void exportAs('png')
setExportOpen(false)
}}
>
<Download /> PNG
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</CardFooter>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset all edits?</AlertDialogTitle>
<AlertDialogDescription>
This reverts the image to the original and discards every edit.
This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
reset()
setResetOpen(false)
}}
>
Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}
export default ImageEditor
Audio Trimmer
A business composition built from the timeline primitives: trim an audio clip with the timeline-element handles, then play back only the trimmed [startTime, startTime + duration] window. The playhead follows playback, the waveform colors in as it plays, and an In / Out / length readout tracks the selection.
'use client'
import {
Download,
Loader2,
Maximize2,
Pause,
Play,
Upload,
ZoomIn,
ZoomOut,
} from 'lucide-react'
import * as React from 'react'
import { AudioWaveform } from '@/components/audio-waveform'
import type { TimelineElementResize } from '@/components/timeline-element'
import { TimelineElement } from '@/components/timeline-element'
import { TimelinePlayhead } from '@/components/timeline-playhead'
import { TimelineRuler } from '@/components/timeline-ruler'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Slider } from '@/components/ui/slider'
const SAMPLE_AUDIO_URL =
'https://hj-video.zeroaigen.cn/prod/USER/AUDIO/f8f39aefee5a61105e18e1a19b501253.mp3'
const ZOOM_MAX = 10
// Floor for the slider so you can always zoom out past fit-to-screen and give
// the trim handles breathing room. Lowered to `fitZoom` when a clip is so long
// it only fits below this — so "Fit" stays reachable. (bycut uses a fixed range.)
const ZOOM_MIN = 0.5
const RULER_HEIGHT = 24
// Matches TimelinePlayhead's knob diameter — the track is padded by half this
// on each side so the knob stays fully visible at either end.
const PLAYHEAD_KNOB = 12
export interface AudioTrimmerProps {
/** Audio to load, visualize and trim. Falls back to a bundled sample. */
audioUrl?: string
/** Base pixels per second at zoom = 1. Default: `50`. */
pixelsPerSecond?: number
/** Track height in CSS px. Default: `56`. */
height?: number
/** Fired with the trimmed selection while dragging the handles. */
onChange?: (selection: TimelineElementResize) => void
/** Fired with the exported WAV blob after a trim. */
onExport?: (blob: Blob) => void
}
/** `mm:ss.s` */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`
}
/** Linear slider position (0–1) → exponential zoom, so low values step gently. */
function sliderToZoom(position: number, min: number, max: number): number {
if (max <= min) return min
const p = Math.max(0, Math.min(1, position))
return min * (max / min) ** p
}
/** Inverse of `sliderToZoom`. */
function zoomToSlider(zoom: number, min: number, max: number): number {
if (max <= min) return 0
const z = Math.max(min, Math.min(max, zoom))
return Math.log(z / min) / Math.log(max / min)
}
/** A small labelled, monospaced readout — used in the footer status bar. */
function Stat({ label, value }: { label: string; value: string }) {
return (
<span className="flex flex-col leading-tight">
<span className="text-muted-foreground/70 text-[10px] font-medium uppercase tracking-wide">
{label}
</span>
<span className="text-foreground text-sm font-medium tabular-nums">
{value}
</span>
</span>
)
}
/**
* Audio trimmer — a business composition built from the timeline primitives.
* Drag the clip's handles to set in / out points, play back only the trimmed
* `[startTime, startTime + duration]` window, then **export the cut as a WAV**
* via mediabunny's `Conversion({ trim })`. Zoom + scroll the timeline; load your
* own audio with the picker.
*/
export function AudioTrimmer({
audioUrl = SAMPLE_AUDIO_URL,
pixelsPerSecond = 50,
height = 56,
onChange,
onExport,
}: AudioTrimmerProps) {
// The source: an uploaded file (preferred) or the `audioUrl` prop.
const [file, setFile] = React.useState<File | null>(null)
const [objectUrl, setObjectUrl] = React.useState<string | null>(null)
const src = objectUrl ?? audioUrl
const [total, setTotal] = React.useState(0)
const [clip, setClip] = React.useState<TimelineElementResize | null>(null)
const [time, setTime] = React.useState(0)
const [playing, setPlaying] = React.useState(false)
const [exporting, setExporting] = React.useState(false)
const [progress, setProgress] = React.useState(0)
const [zoom, setZoom] = React.useState(1)
const [containerWidth, setContainerWidth] = React.useState(0)
const audioRef = React.useRef<HTMLAudioElement | null>(null)
const onChangeRef = React.useRef(onChange)
onChangeRef.current = onChange
// Auto-fit the zoom once per source, after the width is known.
const didFitRef = React.useRef(false)
const resizeObserverRef = React.useRef<ResizeObserver | null>(null)
// Measure the available width (callback ref re-attaches when the node mounts).
const measureRef = React.useCallback((el: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect()
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth))
ro.observe(el)
resizeObserverRef.current = ro
}, [])
// Object URL lifecycle for an uploaded file.
React.useEffect(() => {
if (!file) return
const url = URL.createObjectURL(file)
setObjectUrl(url)
return () => {
URL.revokeObjectURL(url)
setObjectUrl(null)
}
}, [file])
// Read the duration from metadata and reset the selection to the full clip.
React.useEffect(() => {
setTotal(0)
setClip(null)
setTime(0)
setPlaying(false)
didFitRef.current = false
const audio = new Audio()
audio.preload = 'metadata'
audio.src = src
const onMeta = () => {
setTotal(audio.duration)
setClip({ startTime: 0, duration: audio.duration })
}
audio.addEventListener('loadedmetadata', onMeta)
return () => audio.removeEventListener('loadedmetadata', onMeta)
}, [src])
// Playback element.
React.useEffect(() => {
const audio = new Audio(src)
audioRef.current = audio
const onTime = () => setTime(audio.currentTime)
const onEnded = () => setPlaying(false)
audio.addEventListener('timeupdate', onTime)
audio.addEventListener('ended', onEnded)
return () => {
audio.pause()
audio.removeEventListener('timeupdate', onTime)
audio.removeEventListener('ended', onEnded)
audioRef.current = null
}
}, [src])
// Zoom that fits the whole clip in the available width. Subtract the track
// padding (half a knob each side) so the filled timeline lands exactly on the
// viewport edge instead of leaving a sliver of scrollable overflow.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(
ZOOM_MAX,
(containerWidth - PLAYHEAD_KNOB) / (total * pixelsPerSecond),
)
: 1
const minZoom = Math.min(ZOOM_MIN, fitZoom)
const maxZoom = Math.max(ZOOM_MAX, fitZoom)
// Fit once, when the width and duration first become known for a source.
React.useEffect(() => {
if (!total || containerWidth <= 0 || didFitRef.current) return
setZoom(fitZoom)
didFitRef.current = true
}, [total, containerWidth, fitZoom])
const updateClip = (next: TimelineElementResize) => {
setClip(next)
onChangeRef.current?.(next)
}
// Trim the selected window to a WAV blob with mediabunny, then download it.
const exportClip = async () => {
if (!clip) return
setExporting(true)
setProgress(0)
try {
const {
Input,
Output,
Conversion,
BlobSource,
BufferTarget,
WavOutputFormat,
ALL_FORMATS,
} = await import('mediabunny')
let source: Blob
if (file) {
source = file
} else {
source = await (await fetch(src)).blob()
}
const input = new Input({
source: new BlobSource(source),
formats: ALL_FORMATS,
})
const output = new Output({
format: new WavOutputFormat(),
target: new BufferTarget(),
})
const conversion = await Conversion.init({
input,
output,
trim: { start: clip.startTime, end: clip.startTime + clip.duration },
})
conversion.onProgress = setProgress
await conversion.execute()
const wav = new Blob([output.target.buffer as ArrayBuffer], {
type: 'audio/wav',
})
onExport?.(wav)
const name = (file?.name ?? 'audio').replace(/\.[^.]+$/, '')
const url = URL.createObjectURL(wav)
const a = document.createElement('a')
a.href = url
a.download = `${name}-trimmed.wav`
a.click()
URL.revokeObjectURL(url)
} finally {
setExporting(false)
}
}
if (!total || !clip) {
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{/* Toolbar — play + time on the left, zoom on the right. */}
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="ml-auto h-7 w-44" />
</div>
{/* Timeline — ruler over the waveform band. */}
<div className="bg-muted/30 flex flex-col gap-2 rounded-lg p-3">
<Skeleton className="bg-muted-foreground/15 h-3 w-full" />
<Skeleton
className="bg-muted-foreground/15 w-full"
style={{ height }}
/>
</div>
</CardContent>
{/* Footer — selection stats on the left, actions on the right. */}
<CardFooter className="gap-6">
<Skeleton className="h-8 w-12" />
<Skeleton className="h-8 w-12" />
<Skeleton className="h-8 w-12" />
<div className="ml-auto flex items-center gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
</CardFooter>
</Card>
)
}
const out = clip.startTime + clip.duration
const pps = pixelsPerSecond * zoom
const width = total * pps
const sliderPos = zoomToSlider(zoom, minZoom, maxZoom)
const applySlider = (next: number) => {
didFitRef.current = true
setZoom(sliderToZoom(next, minZoom, maxZoom))
}
const stepZoom = (delta: number) => applySlider(sliderPos + delta)
const fit = () => {
didFitRef.current = true
setZoom(fitZoom)
}
const seek = (next: number) => {
const audio = audioRef.current
if (audio) audio.currentTime = next
setTime(next)
}
// Click / drag anywhere on the timeline to move the playhead and scrub.
// Pointer-downs on the playhead and trim handles stop propagation, so those
// gestures still win over seeking.
const scrubFrom = (event: React.PointerEvent<HTMLDivElement>) => {
const el = event.currentTarget
const toTime = (clientX: number) => {
const rect = el.getBoundingClientRect()
return Math.min(total, Math.max(0, (clientX - rect.left) / pps))
}
seek(toTime(event.clientX))
const onMove = (e: PointerEvent) => seek(toTime(e.clientX))
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (playing) {
audio.pause()
setPlaying(false)
return
}
// Restart from the top when parked at the end.
if (time >= total) {
audio.currentTime = 0
setTime(0)
}
void audio.play()
setPlaying(true)
}
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{/* Toolbar — transport on the left, zoom + fit on the right. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<Button
type="button"
size="icon-lg"
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play selection'}
className="rounded-full"
>
{playing ? <Pause /> : <Play className="translate-x-px" />}
</Button>
<span className="text-muted-foreground text-xs tabular-nums">
<span className="text-foreground font-medium">
{formatTime(time)}
</span>{' '}
/ {formatTime(total)}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Zoom out"
onClick={() => stepZoom(-0.1)}
>
<ZoomOut />
</Button>
<div className="w-28">
<Slider
min={0}
max={100}
value={[sliderPos * 100]}
onValueChange={(value) =>
applySlider((Array.isArray(value) ? value[0] : value) / 100)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
title="Zoom in"
onClick={() => stepZoom(0.1)}
>
<ZoomIn />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Fit to width"
onClick={fit}
>
<Maximize2 />
</Button>
<span className="text-muted-foreground w-9 text-right text-xs tabular-nums">
{Math.max(1, Math.round(sliderPos * 100))}%
</span>
</div>
</div>
{/* Timeline — the hero; scrolls horizontally when zoomed past the view. */}
<div className="bg-muted/30 rounded-lg p-3">
<div ref={measureRef}>
<ScrollArea
style={{ height: RULER_HEIGHT + 8 + height + PLAYHEAD_KNOB + 8 }}
>
{/* Pad the scroll content by half a knob on every side so the
playhead circle stays fully visible at the start, end and top.
The inner track is the positioning origin shared by the ruler,
waveform and playhead, so they all stay aligned. */}
<div
style={{
width: width + PLAYHEAD_KNOB,
minWidth: '100%',
padding: PLAYHEAD_KNOB / 2,
boxSizing: 'border-box',
}}
>
<div
style={{
position: 'relative',
width,
minWidth: '100%',
cursor: 'pointer',
}}
onPointerDown={scrubFrom}
>
<TimelineRuler
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={RULER_HEIGHT}
/>
<div
className="mt-2"
style={{
position: 'relative',
width,
height,
overflow: 'hidden',
}}
>
{/* Full waveform — always visible, so you can see what is
being cut away (and whether there is audio there). */}
<AudioWaveform
audioUrl={src}
width={Math.ceil(width)}
height={height}
barColor="rgba(148, 148, 173, 0.55)"
barPlayedColor="rgba(129, 140, 248, 0.95)"
progress={time / total}
/>
{/* Spotlight — dim everything outside the selection instead of
hiding it. The large spread shadow follows the rounded
corners, so the dim hugs the selection frame exactly (no
square-vs-rounded notch at the corners). Clipped to the
waveform band by the parent's overflow. */}
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: clip.startTime * pps,
width: clip.duration * pps,
borderRadius: 8,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.2)',
pointerEvents: 'none',
}}
/>
{/* Selection window — a transparent frame (border + draggable
trim handles only) so the waveform inside stays visible. */}
<TimelineElement
startTime={clip.startTime}
duration={clip.duration}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
height={height}
minDuration={0.5}
maxEnd={total}
selected
movable
color="transparent"
onResize={updateClip}
/>
</div>
<TimelinePlayhead
currentTime={time}
duration={total}
pixelsPerSecond={pixelsPerSecond}
zoom={zoom}
onSeek={seek}
/>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</CardContent>
{/* Footer — selection summary on the left, source / export on the right. */}
<CardFooter className="gap-4">
<Stat label="In" value={formatTime(clip.startTime)} />
<Stat label="Out" value={formatTime(out)} />
<Stat label="Length" value={formatTime(clip.duration)} />
<div className="ml-auto flex items-center gap-2">
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load audio
<input
type="file"
accept="audio/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
<Button
type="button"
onClick={() => void exportClip()}
disabled={exporting}
>
{exporting ? (
<>
<Loader2 className="animate-spin" />
{Math.round(progress * 100)}%
</>
) : (
<>
<Download />
Export clip
</>
)}
</Button>
</div>
</CardFooter>
</Card>
)
}
export default AudioTrimmer
Media Compressor
A business composition over mediabunny's Conversion: load an audio or video file, pick a quality preset and (for video) a target resolution, then re-encode to a smaller MP4 — video as H.264 + AAC, audio-only as AAC. A before/after readout shows the original size, the compressed size, and the percentage saved; the result auto-downloads.
'use client'
import { Download, Loader2, Upload } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { WaveformPlayer } from '@/components/waveform-player'
const SAMPLE_VIDEO_URL =
'https://hj-video.zeroaigen.cn/prod/AI/VIDEO/f4e7fdc9807348eedc1e64a963c7433e.mp4'
type Quality = 'low' | 'medium' | 'high'
type Resolution = 'keep' | '1080' | '720' | '480' | '360'
interface SourceMeta {
blob: Blob
url: string
kind: 'audio' | 'video'
width: number
height: number
}
export interface MediaCompressorProps {
/** Media to load and compress. Falls back to a bundled sample video. */
mediaUrl?: string
/** Initial quality preset. Default: `'medium'`. */
quality?: Quality
/** Fired with the compressed MP4 blob after a run. */
onExport?: (blob: Blob) => void
}
const QUALITY_LABEL: Record<Quality, string> = {
low: 'Low — smallest',
medium: 'Medium — balanced',
high: 'High — best quality',
}
const RESOLUTION_LABEL: Record<Resolution, string> = {
keep: 'Keep original',
'1080': '1080p',
'720': '720p',
'480': '480p',
'360': '360p',
}
/** Human-readable byte size, e.g. `4.2 MB`. */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(1)} ${units[unit]}`
}
/** Load a blob's media metadata (kind + intrinsic dimensions) off-screen. */
function probe(
url: string,
kind: 'audio' | 'video',
): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
if (kind === 'audio') {
resolve({ width: 0, height: 0 })
return
}
const el = document.createElement('video')
el.preload = 'metadata'
el.src = url
el.onloadedmetadata = () =>
resolve({ width: el.videoWidth, height: el.videoHeight })
el.onerror = () => resolve({ width: 0, height: 0 })
})
}
/** A small labelled, monospaced readout — used in the footer status bar. */
function Stat({ label, value }: { label: string; value: string }) {
return (
<span className="flex flex-col leading-tight">
<span className="text-muted-foreground/70 text-[10px] font-medium uppercase tracking-wide">
{label}
</span>
<span className="text-foreground text-sm font-medium tabular-nums">
{value}
</span>
</span>
)
}
/**
* Media compressor — a business composition over mediabunny's `Conversion`.
* Load an audio or video file (or use the sample), pick a quality preset and,
* for video, a target resolution, then **re-encode to a smaller MP4**: video as
* H.264 + AAC, audio-only as AAC. The before/after readout shows how much was
* saved; the result auto-downloads.
*/
export function MediaCompressor({
mediaUrl = SAMPLE_VIDEO_URL,
quality: initialQuality = 'medium',
onExport,
}: MediaCompressorProps) {
const [file, setFile] = React.useState<File | null>(null)
const [meta, setMeta] = React.useState<SourceMeta | null>(null)
const [quality, setQuality] = React.useState<Quality>(initialQuality)
const [resolution, setResolution] = React.useState<Resolution>('keep')
const [compressing, setCompressing] = React.useState(false)
const [progress, setProgress] = React.useState(0)
const [result, setResult] = React.useState<{
size: number
width: number
height: number
} | null>(null)
const [error, setError] = React.useState<string | null>(null)
// Resolve the source to a Blob (the uploaded File, or the fetched URL), then
// probe its kind + dimensions. One fetch up front so the original size and the
// compression ratio are always known, and the same blob feeds the encoder.
React.useEffect(() => {
let cancelled = false
let objectUrl: string | null = null
setMeta(null)
setResult(null)
setError(null)
setResolution('keep')
void (async () => {
try {
const blob = file ?? (await (await fetch(mediaUrl)).blob())
if (cancelled) return
const kind: 'audio' | 'video' = blob.type.startsWith('audio')
? 'audio'
: 'video'
objectUrl = URL.createObjectURL(blob)
const { width, height } = await probe(objectUrl, kind)
if (cancelled) {
URL.revokeObjectURL(objectUrl)
return
}
setMeta({ blob, url: objectUrl, kind, width, height })
} catch {
if (!cancelled) setError('Could not load the media.')
}
})()
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [file, mediaUrl])
// Re-encode the source to a smaller MP4 with mediabunny, then download it.
const compress = async () => {
if (!meta) return
setCompressing(true)
setProgress(0)
setError(null)
setResult(null)
try {
const {
Input,
Output,
Conversion,
BlobSource,
BufferTarget,
Mp4OutputFormat,
ALL_FORMATS,
QUALITY_LOW,
QUALITY_MEDIUM,
QUALITY_HIGH,
} = await import('mediabunny')
const bitrate = {
low: QUALITY_LOW,
medium: QUALITY_MEDIUM,
high: QUALITY_HIGH,
}[quality]
const targetHeight =
resolution === 'keep' ? undefined : Number(resolution)
const input = new Input({
source: new BlobSource(meta.blob),
formats: ALL_FORMATS,
})
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
})
const conversion = await Conversion.init({
input,
output,
video:
meta.kind === 'video'
? { height: targetHeight, fit: 'contain', bitrate }
: undefined,
audio: { bitrate },
})
conversion.onProgress = setProgress
await conversion.execute()
const buffer = output.target.buffer as ArrayBuffer
const mp4 = new Blob([buffer], { type: 'video/mp4' })
onExport?.(mp4)
// Output dimensions: target height (contain-scaled width) or the original.
const outHeight = targetHeight ?? meta.height
const outWidth =
targetHeight && meta.height
? Math.round((meta.width * targetHeight) / meta.height)
: meta.width
setResult({ size: mp4.size, width: outWidth, height: outHeight })
const ext = meta.kind === 'video' ? 'mp4' : 'm4a'
const name = (file?.name ?? 'media').replace(/\.[^.]+$/, '')
const url = URL.createObjectURL(mp4)
const a = document.createElement('a')
a.href = url
a.download = `${name}-compressed.${ext}`
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Compression failed — this codec may be unsupported here.')
} finally {
setCompressing(false)
}
}
if (!meta) {
if (error) {
return (
<Card className="w-full">
<CardContent className="flex flex-col items-center gap-4 pt-(--card-spacing)">
<p className="text-destructive text-sm">{error}</p>
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load media
<input
type="file"
accept="audio/*,video/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
<Skeleton className="mx-auto aspect-video w-full max-w-md" />
<div className="flex flex-wrap gap-3">
<Skeleton className="h-8 w-44" />
<Skeleton className="h-8 w-36" />
</div>
</CardContent>
<CardFooter className="gap-6">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-16" />
<div className="ml-auto flex items-center gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-28" />
</div>
</CardFooter>
</Card>
)
}
const originalSize = meta.blob.size
const saved =
result && originalSize > 0
? Math.round((1 - result.size / originalSize) * 100)
: null
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 pt-(--card-spacing)">
{/* Preview — the source media. */}
{meta.kind === 'video' ? (
<div className="bg-muted/30 mx-auto flex aspect-video w-full max-w-md items-center justify-center overflow-hidden rounded-lg">
<video
src={meta.url}
controls
playsInline
muted
className="h-full w-full object-contain"
/>
</div>
) : (
<div className="bg-muted/30 rounded-lg p-4">
<WaveformPlayer
blob={meta.blob}
barColor="rgba(148, 148, 173, 0.55)"
barPlayedColor="rgba(129, 140, 248, 0.95)"
/>
</div>
)}
{/* Settings — quality always; resolution for video only. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Quality</span>
<Select
value={quality}
disabled={compressing}
onValueChange={(value) => setQuality(value as Quality)}
>
<SelectTrigger>
<SelectValue>
{(value: Quality) => QUALITY_LABEL[value]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(QUALITY_LABEL) as Quality[]).map((q) => (
<SelectItem key={q} value={q}>
{QUALITY_LABEL[q]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{meta.kind === 'video' && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Resolution</span>
<Select
value={resolution}
disabled={compressing}
onValueChange={(value) => setResolution(value as Resolution)}
>
<SelectTrigger>
<SelectValue>
{(value: Resolution) => RESOLUTION_LABEL[value]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(RESOLUTION_LABEL) as Resolution[]).map((r) => (
<SelectItem key={r} value={r}>
{RESOLUTION_LABEL[r]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
</CardContent>
{/* Footer — before/after readout on the left, source / compress on the right. */}
<CardFooter className="gap-4">
<Stat label="Original" value={formatBytes(originalSize)} />
<Stat
label="Compressed"
value={result ? formatBytes(result.size) : '—'}
/>
<Stat label="Saved" value={saved === null ? '—' : `${saved}%`} />
<div className="ml-auto flex items-center gap-2">
<Button render={<label />} nativeButton={false} variant="outline">
<Upload />
Load media
<input
type="file"
accept="audio/*,video/*"
className="hidden"
onChange={(event) => {
const next = event.target.files?.[0]
if (next) setFile(next)
}}
/>
</Button>
<Button
type="button"
onClick={() => void compress()}
disabled={compressing}
>
{compressing ? (
<>
<Loader2 className="animate-spin" />
{Math.round(progress * 100)}%
</>
) : (
<>
<Download />
Compress
</>
)}
</Button>
</div>
</CardFooter>
</Card>
)
}
export default MediaCompressor