Blocks
Ready-made business compositions built from the ikui primitives — previewed live, with the full source. Copy, paste, or install with the CLI.
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
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.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(ZOOM_MAX, containerWidth / (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-3 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-3 pt-(--card-spacing)">
{/* Toolbar — transport on the left, zoom + fit on the right. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<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 screen"
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 + 16 }}>
<div
style={{ position: 'relative', width, 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>
<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
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
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` */
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.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(ZOOM_MAX, containerWidth / (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-3 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-3 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-3 gap-y-2">
<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 screen"
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 + 16 }}>
<div
style={{ position: 'relative', width, 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>
<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
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) {
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
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
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
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[] }
function formatTime(seconds: number): string {
const total = Math.round(seconds)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '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.
const fitZoom =
total > 0 && containerWidth > 0
? Math.min(ZOOM_MAX, containerWidth / (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
let last = performance.now()
const tick = (now: number) => {
const dt = (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-3">
<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>
<p className="text-destructive text-sm">
Failed to load shots: {state.message}
</p>
</CardContent>
</Card>
)
}
if (state.items.length === 0) {
return (
<Card className="w-full">
<CardContent>
<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">
{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 items-center gap-3 text-sm">
<Button
variant="default"
size="icon"
className="rounded-full"
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing ? <Pause /> : <Play />}
</Button>
<span className="text-muted-foreground tabular-nums">
{formatTime(currentTime)} / {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 + 16 }}>
<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>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</CardContent>
</Card>
)
}
export default StoryboardTimeline