1+ "use client" ;
2+
3+ import { useEffect , useRef , useState , useCallback } from "react" ;
4+
5+ interface Thumbnail {
6+ time : number ;
7+ dataUrl : string ;
8+ }
9+
10+ interface ThumbnailStripProps {
11+ videoSrc : string | null ;
12+ duration : number ;
13+ currentTime : number ;
14+ trimStart ?: number ;
15+ trimEnd ?: number ;
16+ onSeek : ( time : number ) => void ;
17+ intervalSeconds ?: number ;
18+ }
19+
20+ export default function ThumbnailStrip ( {
21+ videoSrc,
22+ duration,
23+ currentTime,
24+ trimStart = 0 ,
25+ trimEnd,
26+ onSeek,
27+ intervalSeconds = 5 ,
28+ } : ThumbnailStripProps ) {
29+ const [ thumbnails , setThumbnails ] = useState < Thumbnail [ ] > ( [ ] ) ;
30+ const [ isGenerating , setIsGenerating ] = useState ( false ) ;
31+ const [ progress , setProgress ] = useState ( 0 ) ;
32+ const [ hoveredIndex , setHoveredIndex ] = useState < number | null > ( null ) ;
33+ const stripRef = useRef < HTMLDivElement > ( null ) ;
34+ const offscreenVideoRef = useRef < HTMLVideoElement | null > ( null ) ;
35+ const abortRef = useRef ( false ) ;
36+
37+ const effectiveTrimEnd = trimEnd ?? duration ;
38+
39+ const generateThumbnails = useCallback ( async ( ) => {
40+ if ( ! videoSrc || duration <= 0 ) return ;
41+
42+ abortRef . current = false ;
43+ setIsGenerating ( true ) ;
44+ setThumbnails ( [ ] ) ;
45+ setProgress ( 0 ) ;
46+
47+ const video = document . createElement ( "video" ) ;
48+ video . src = videoSrc ;
49+ video . crossOrigin = "anonymous" ;
50+ video . muted = true ;
51+ video . preload = "auto" ;
52+ offscreenVideoRef . current = video ;
53+
54+ await new Promise < void > ( ( resolve , reject ) => {
55+ video . onloadedmetadata = ( ) => resolve ( ) ;
56+ video . onerror = ( ) => reject ( new Error ( "Video load failed" ) ) ;
57+ video . load ( ) ;
58+ } ) ;
59+
60+ const canvas = document . createElement ( "canvas" ) ;
61+ const ctx = canvas . getContext ( "2d" ) ;
62+ if ( ! ctx ) return ;
63+
64+ const thumbW = 160 ;
65+ const thumbH = 90 ;
66+ canvas . width = thumbW ;
67+ canvas . height = thumbH ;
68+
69+ const times : number [ ] = [ ] ;
70+ for ( let t = 0 ; t <= duration ; t += intervalSeconds ) {
71+ times . push ( Math . min ( t , duration - 0.1 ) ) ;
72+ }
73+ if ( times [ times . length - 1 ] < duration - 0.5 ) {
74+ times . push ( duration - 0.1 ) ;
75+ }
76+
77+ const captured : Thumbnail [ ] = [ ] ;
78+
79+ for ( let i = 0 ; i < times . length ; i ++ ) {
80+ if ( abortRef . current ) break ;
81+
82+ const time = times [ i ] ;
83+ await new Promise < void > ( ( resolve ) => {
84+ const onSeeked = ( ) => {
85+ video . removeEventListener ( "seeked" , onSeeked ) ;
86+ ctx . drawImage ( video , 0 , 0 , thumbW , thumbH ) ;
87+ captured . push ( { time, dataUrl : canvas . toDataURL ( "image/jpeg" , 0.7 ) } ) ;
88+ setThumbnails ( [ ...captured ] ) ;
89+ setProgress ( Math . round ( ( ( i + 1 ) / times . length ) * 100 ) ) ;
90+ resolve ( ) ;
91+ } ;
92+ video . addEventListener ( "seeked" , onSeeked ) ;
93+ video . currentTime = time ;
94+ } ) ;
95+ }
96+
97+ video . src = "" ;
98+ offscreenVideoRef . current = null ;
99+ setIsGenerating ( false ) ;
100+ } , [ videoSrc , duration , intervalSeconds ] ) ;
101+
102+ useEffect ( ( ) => {
103+ if ( videoSrc && duration > 0 ) {
104+ generateThumbnails ( ) ;
105+ }
106+ return ( ) => {
107+ abortRef . current = true ;
108+ } ;
109+ } , [ generateThumbnails ] ) ;
110+
111+ const formatTime = ( seconds : number ) => {
112+ const m = Math . floor ( seconds / 60 ) ;
113+ const s = Math . floor ( seconds % 60 ) ;
114+ return `${ m } :${ s . toString ( ) . padStart ( 2 , "0" ) } ` ;
115+ } ;
116+
117+ const activeIndex = thumbnails . findIndex (
118+ ( t , i ) =>
119+ currentTime >= t . time &&
120+ ( i === thumbnails . length - 1 || currentTime < thumbnails [ i + 1 ] . time )
121+ ) ;
122+
123+ if ( ! videoSrc ) return null ;
124+
125+ return (
126+ < div className = "thumbnail-strip-wrapper" >
127+ < div className = "strip-header" >
128+ < span className = "strip-label" >
129+ < svg width = "12" height = "12" viewBox = "0 0 12 12" fill = "none" >
130+ < rect x = "0.5" y = "0.5" width = "11" height = "11" rx = "1.5" stroke = "currentColor" />
131+ < rect x = "3" y = "2.5" width = "1.5" height = "7" rx = "0.5" fill = "currentColor" />
132+ < rect x = "7.5" y = "2.5" width = "1.5" height = "7" rx = "0.5" fill = "currentColor" />
133+ </ svg >
134+ Frames
135+ </ span >
136+ { isGenerating && (
137+ < span className = "strip-progress" >
138+ < span
139+ className = "progress-bar"
140+ style = { { width : `${ progress } %` } }
141+ />
142+ < span className = "progress-text" > { progress } %</ span >
143+ </ span >
144+ ) }
145+ { ! isGenerating && thumbnails . length > 0 && (
146+ < span className = "strip-meta" >
147+ { thumbnails . length } frames · every { intervalSeconds } s
148+ </ span >
149+ ) }
150+ </ div >
151+
152+ < div className = "strip-scroll-area" ref = { stripRef } >
153+ { thumbnails . length === 0 && isGenerating && (
154+ < div className = "strip-skeleton" >
155+ { Array . from ( { length : 8 } ) . map ( ( _ , i ) => (
156+ < div key = { i } className = "skeleton-thumb" style = { { animationDelay : `${ i * 80 } ms` } } />
157+ ) ) }
158+ </ div >
159+ ) }
160+
161+ { thumbnails . length > 0 && (
162+ < div className = "strip-inner" >
163+ { thumbnails . map ( ( thumb , i ) => {
164+ const isActive = i === activeIndex ;
165+ const inTrimRange =
166+ thumb . time >= trimStart && thumb . time <= effectiveTrimEnd ;
167+ const isHovered = hoveredIndex === i ;
168+
169+ return (
170+ < button
171+ key = { thumb . time }
172+ className = { `thumb-btn ${ isActive ? "active" : "" } ${
173+ ! inTrimRange ? "out-of-range" : ""
174+ } ${ isHovered ? "hovered" : "" } `}
175+ onClick = { ( ) => onSeek ( thumb . time ) }
176+ onMouseEnter = { ( ) => setHoveredIndex ( i ) }
177+ onMouseLeave = { ( ) => setHoveredIndex ( null ) }
178+ title = { `Seek to ${ formatTime ( thumb . time ) } ` }
179+ >
180+ < img
181+ src = { thumb . dataUrl }
182+ alt = { `Frame at ${ formatTime ( thumb . time ) } ` }
183+ draggable = { false }
184+ />
185+ < span className = "thumb-time" > { formatTime ( thumb . time ) } </ span >
186+ { isActive && < span className = "active-indicator" /> }
187+ </ button >
188+ ) ;
189+ } ) }
190+ </ div >
191+ ) }
192+ </ div >
193+
194+ < style > { `
195+ .thumbnail-strip-wrapper {
196+ width: 100%;
197+ background: #0d0d0f;
198+ border: 1px solid #1e1e24;
199+ border-radius: 10px;
200+ overflow: hidden;
201+ font-family: 'SF Mono', 'Fira Code', monospace;
202+ }
203+
204+ .strip-header {
205+ display: flex;
206+ align-items: center;
207+ gap: 12px;
208+ padding: 8px 14px;
209+ background: #111115;
210+ border-bottom: 1px solid #1e1e24;
211+ }
212+
213+ .strip-label {
214+ display: flex;
215+ align-items: center;
216+ gap: 6px;
217+ font-size: 10px;
218+ font-weight: 600;
219+ letter-spacing: 0.08em;
220+ text-transform: uppercase;
221+ color: #5a5a72;
222+ }
223+
224+ .strip-progress {
225+ position: relative;
226+ flex: 1;
227+ height: 3px;
228+ background: #1e1e24;
229+ border-radius: 2px;
230+ overflow: hidden;
231+ display: flex;
232+ align-items: center;
233+ }
234+
235+ .progress-bar {
236+ position: absolute;
237+ left: 0;
238+ top: 0;
239+ height: 100%;
240+ background: linear-gradient(90deg, #4f6ef7, #a78bfa);
241+ border-radius: 2px;
242+ transition: width 0.2s ease;
243+ }
244+
245+ .progress-text {
246+ position: absolute;
247+ right: -28px;
248+ font-size: 9px;
249+ color: #5a5a72;
250+ white-space: nowrap;
251+ }
252+
253+ .strip-meta {
254+ margin-left: auto;
255+ font-size: 10px;
256+ color: #3a3a50;
257+ }
258+
259+ .strip-scroll-area {
260+ overflow-x: auto;
261+ overflow-y: hidden;
262+ padding: 10px 10px 6px;
263+ scrollbar-width: thin;
264+ scrollbar-color: #2a2a35 transparent;
265+ }
266+
267+ .strip-scroll-area::-webkit-scrollbar {
268+ height: 4px;
269+ }
270+
271+ .strip-scroll-area::-webkit-scrollbar-track {
272+ background: transparent;
273+ }
274+
275+ .strip-scroll-area::-webkit-scrollbar-thumb {
276+ background: #2a2a35;
277+ border-radius: 2px;
278+ }
279+
280+ .strip-skeleton {
281+ display: flex;
282+ gap: 6px;
283+ }
284+
285+ .skeleton-thumb {
286+ width: 106px;
287+ height: 60px;
288+ border-radius: 6px;
289+ background: linear-gradient(90deg, #111115 25%, #1a1a22 50%, #111115 75%);
290+ background-size: 200% 100%;
291+ animation: shimmer 1.4s infinite;
292+ flex-shrink: 0;
293+ }
294+
295+ @keyframes shimmer {
296+ 0% { background-position: 200% 0; }
297+ 100% { background-position: -200% 0; }
298+ }
299+
300+ .strip-inner {
301+ display: flex;
302+ gap: 6px;
303+ align-items: flex-end;
304+ }
305+
306+ .thumb-btn {
307+ position: relative;
308+ padding: 0;
309+ border: none;
310+ background: none;
311+ cursor: pointer;
312+ border-radius: 6px;
313+ overflow: hidden;
314+ flex-shrink: 0;
315+ width: 106px;
316+ height: 60px;
317+ transition: transform 0.15s ease, box-shadow 0.15s ease;
318+ outline: 2px solid transparent;
319+ outline-offset: 1px;
320+ }
321+
322+ .thumb-btn img {
323+ width: 100%;
324+ height: 100%;
325+ object-fit: cover;
326+ display: block;
327+ border-radius: 6px;
328+ filter: brightness(0.85);
329+ transition: filter 0.15s ease;
330+ }
331+
332+ .thumb-btn:hover img,
333+ .thumb-btn.hovered img {
334+ filter: brightness(1.05);
335+ }
336+
337+ .thumb-btn:hover,
338+ .thumb-btn.hovered {
339+ transform: translateY(-3px) scale(1.04);
340+ box-shadow: 0 8px 24px rgba(0,0,0,0.6);
341+ outline-color: rgba(79, 110, 247, 0.5);
342+ z-index: 2;
343+ }
344+
345+ .thumb-btn.active {
346+ outline-color: #4f6ef7;
347+ box-shadow: 0 0 0 2px #4f6ef7, 0 8px 20px rgba(79,110,247,0.3);
348+ z-index: 3;
349+ }
350+
351+ .thumb-btn.active img {
352+ filter: brightness(1.1);
353+ }
354+
355+ .thumb-btn.out-of-range img {
356+ filter: brightness(0.35) saturate(0.2);
357+ }
358+
359+ .thumb-time {
360+ position: absolute;
361+ bottom: 0;
362+ left: 0;
363+ right: 0;
364+ padding: 3px 4px 3px;
365+ background: linear-gradient(transparent, rgba(0,0,0,0.85));
366+ font-size: 9px;
367+ color: rgba(255,255,255,0.75);
368+ text-align: center;
369+ letter-spacing: 0.04em;
370+ pointer-events: none;
371+ border-radius: 0 0 6px 6px;
372+ }
373+
374+ .thumb-btn.active .thumb-time {
375+ color: #a5b4fc;
376+ }
377+
378+ .active-indicator {
379+ position: absolute;
380+ top: 4px;
381+ right: 4px;
382+ width: 6px;
383+ height: 6px;
384+ border-radius: 50%;
385+ background: #4f6ef7;
386+ box-shadow: 0 0 6px #4f6ef7;
387+ animation: pulse-dot 1.5s ease-in-out infinite;
388+ }
389+
390+ @keyframes pulse-dot {
391+ 0%, 100% { opacity: 1; transform: scale(1); }
392+ 50% { opacity: 0.5; transform: scale(0.7); }
393+ }
394+ ` } </ style >
395+ </ div >
396+ ) ;
397+ }
0 commit comments