feat(cms): add VDO Frame Scrubber block with isolated sequence library

This commit is contained in:
Your Name committed 2026-10-17 11:23:08 +07:00
1 parent 64f7ecf729
commit 0bf544720b
877 files changed
+1632 -6

No files matched your search

@@ -0,0 +1,474 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import {
Play,
Pause,
ArrowRight,
Maximize2,
Sliders,
ChevronDown,
} from 'lucide-react'
export interface VdoFrameScrubberProps {
folderPath?: string
pattern?: string
padding?: number
totalFrames?: number
fps?: number
durationSeconds?: number
eyebrow?: string
title?: string
subtitle?: string
instruction?: string
productName?: string
productBadge?: string
endCtaTitle?: string
endCtaButtonText?: string
endCtaLink?: string
theme?: 'dark' | 'light'
accentColor?: string
lang?: string
}
export default function VdoFrameScrubber({
folderPath = '/frame-sequences/chrome-sprocket-428h',
pattern = 'frame-{index}.webp',
padding = 3,
totalFrames = 432,
durationSeconds = 18,
eyebrow = 'FROM VIDEO TO INTERACTIVE',
title = 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม',
subtitle = 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์',
instruction = 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ',
productName = 'SPROCKET / FRAME SCRUBBER',
productBadge = '18 SEC / 432 EXTRACTED FRAMES',
endCtaTitle = 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่',
endCtaButtonText = 'สั่งซื้อสินค้าตอนนี้',
endCtaLink,
theme = 'dark',
accentColor = '#d5ff63',
}: VdoFrameScrubberProps) {
const isDark = theme !== 'light'
const scrollSectionRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [currentFrame, setCurrentFrame] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [scrollProgress, setScrollProgress] = useState(0)
// LRU cache for decoded frames (max 16 frames in memory to prevent mobile memory leaks)
const cacheRef = useRef<Map<number, HTMLImageElement>>(new Map())
const pendingRef = useRef<Map<number, Promise<HTMLImageElement | null>>>(new Map())
// Internal state ref for event handlers & animation loops
const stateRef = useRef({
current: 0,
dragging: false,
pointerStart: 0,
frameStart: 0,
playing: false,
playTimer: null as any,
})
// Format path to frame image
const getFrameUrl = useCallback(
(index: number) => {
const idx = ((Math.round(index) % totalFrames) + totalFrames) % totalFrames
const pad = String(idx + 1).padStart(padding, '0')
const base = folderPath.replace(/\/$/, '')
return `${base}/${pattern.replace('{index}', pad)}`
},
[folderPath, pattern, padding, totalFrames]
)
// Preload & Cache a single frame
const loadFrame = useCallback(
(index: number): Promise<HTMLImageElement | null> => {
const idx = ((Math.round(index) % totalFrames) + totalFrames) % totalFrames
const cache = cacheRef.current
const pending = pendingRef.current
if (cache.has(idx)) {
const img = cache.get(idx)!
cache.delete(idx)
cache.set(idx, img)
return Promise.resolve(img)
}
if (pending.has(idx)) {
return pending.get(idx)!
}
const p = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = getFrameUrl(idx)
})
.then((img) => {
pending.delete(idx)
cache.set(idx, img)
while (cache.size > 16) {
cache.delete(cache.keys().next().value!)
}
return img
})
.catch(() => {
pending.delete(idx)
return null
})
pending.set(idx, p)
return p
},
[getFrameUrl, totalFrames]
)
// Paint image onto Canvas with smooth aspect scaling
const paintCanvas = useCallback((img: HTMLImageElement | null) => {
const canvas = canvasRef.current
if (!canvas || !img || !img.naturalWidth || !canvas.width) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const scale = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight)
const dw = img.naturalWidth * scale
const dh = img.naturalHeight * scale
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh)
}, [])
// Show specific frame & preload neighbors [-2, -1, 1, 2]
const showFrame = useCallback(
(val: number) => {
const idx = ((Math.round(val) % totalFrames) + totalFrames) % totalFrames
stateRef.current.current = idx
setCurrentFrame(idx)
const prog = totalFrames > 1 ? idx / (totalFrames - 1) : 0
setScrollProgress(prog)
loadFrame(idx).then((img) => {
if (img && stateRef.current.current === idx) {
paintCanvas(img)
}
})
// Lookahead predictive preload
for (const offset of [-2, -1, 1, 2]) {
loadFrame((idx + offset + totalFrames) % totalFrames)
}
},
[loadFrame, paintCanvas, totalFrames]
)
// Resize canvas according to device pixel ratio
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const rect = canvas.getBoundingClientRect()
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
loadFrame(stateRef.current.current).then(paintCanvas)
}, [loadFrame, paintCanvas])
useEffect(() => {
resizeCanvas()
showFrame(0)
window.addEventListener('resize', resizeCanvas)
return () => window.removeEventListener('resize', resizeCanvas)
}, [resizeCanvas, showFrame])
// Scroll-driven scrubbing
useEffect(() => {
const onScroll = () => {
const s = stateRef.current
if (s.dragging || s.playing) return
const section = scrollSectionRef.current
if (!section) return
const rect = section.getBoundingClientRect()
if (rect.top >= window.innerHeight || rect.bottom <= 0) return
const scrollable = Math.max(1, section.offsetHeight - window.innerHeight)
const fraction = Math.max(0, Math.min(1, -rect.top / scrollable))
showFrame(fraction * (totalFrames - 1))
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [showFrame, totalFrames])
// Play / Pause loop at native 24fps
const togglePlay = () => {
const s = stateRef.current
const nextState = !s.playing
s.playing = nextState
setIsPlaying(nextState)
if (s.playTimer) clearInterval(s.playTimer)
if (nextState) {
const intervalMs = (durationSeconds * 1000) / totalFrames
s.playTimer = setInterval(() => {
showFrame((stateRef.current.current + 1) % totalFrames)
}, intervalMs)
} else {
s.playTimer = null
}
}
// Pointer drag scrubbing handlers
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return
const stage = stageRef.current
if (!stage) return
const s = stateRef.current
s.dragging = true
if (s.playing) {
s.playing = false
setIsPlaying(false)
if (s.playTimer) clearInterval(s.playTimer)
}
s.pointerStart = e.clientX
s.frameStart = s.current
stage.setPointerCapture(e.pointerId)
setIsDragging(true)
}
const handlePointerMove = (e: React.PointerEvent) => {
const s = stateRef.current
if (!s.dragging) return
const stage = stageRef.current
if (!stage) return
const delta = ((e.clientX - s.pointerStart) / Math.max(1, stage.clientWidth)) * totalFrames
showFrame(s.frameStart + delta)
}
const stopDragging = () => {
stateRef.current.dragging = false
setIsDragging(false)
}
// Keyboard controls (Arrow Left / Right)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes((e.target as HTMLElement)?.tagName)) return
if (e.key === 'ArrowLeft') {
showFrame(stateRef.current.current - 1)
} else if (e.key === 'ArrowRight') {
showFrame(stateRef.current.current + 1)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [showFrame])
// Calculate live timecode seconds
const currentSeconds = (currentFrame / totalFrames) * durationSeconds
const formattedTimecode = `00:${currentSeconds.toFixed(1).padStart(4, '0')} / 00:${durationSeconds.toFixed(1)}`
const handleCtaClick = () => {
if (endCtaLink) {
window.location.href = endCtaLink
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
return (
<section
className={`relative w-full ${
isDark ? 'bg-[#0e0e0d] text-[#f4f2ec]' : 'bg-[#f8f9fa] text-slate-900'
} font-sans selection:bg-[${accentColor}] selection:text-black transition-colors duration-500`}
>
{/* ── Section Intro Header ── */}
<div className="pt-24 sm:pt-32 pb-12 px-6 sm:px-12 max-w-6xl mx-auto flex flex-col sm:flex-row items-start sm:items-end justify-between gap-6 border-b border-white/5">
<div className="space-y-3 max-w-2xl">
<div
className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-white/5 border border-white/10 text-[10px] tracking-widest font-mono uppercase"
style={{ color: accentColor }}
>
<span className="w-1.5 h-1.5 rounded-full animate-ping" style={{ backgroundColor: accentColor }} />
{eyebrow}
</div>
<h2 className="text-3xl sm:text-5xl md:text-6xl font-extrabold tracking-tight leading-[1.1]">
{title}
</h2>
<p className="text-sm sm:text-base text-slate-400 font-light leading-relaxed">
{subtitle}
</p>
</div>
<div className="hidden sm:flex flex-col items-end gap-1 text-[11px] font-mono text-slate-500 uppercase tracking-widest">
<span>FRAME BY FRAME SCRUBBER</span>
<span style={{ color: accentColor }}>{totalFrames} FRAMES / {durationSeconds}S</span>
</div>
</div>
{/* ── Scroll Section with Sticky Stage (250vh) ── */}
<div
ref={scrollSectionRef}
className="relative w-full"
style={{ height: '260vh' }}
>
<div className="sticky top-0 h-screen w-full flex flex-col justify-between py-6 sm:py-10 px-4 sm:px-12 overflow-hidden">
{/* Top Info Bar inside Sticky Canvas */}
<div className="w-full flex items-center justify-between z-20 max-w-7xl mx-auto">
<div className="space-y-0.5">
<span className="text-[10px] uppercase font-mono tracking-widest" style={{ color: accentColor }}>
{productBadge}
</span>
<h3 className="text-base sm:text-lg font-bold tracking-tight">
{productName}
</h3>
</div>
{/* Instruction Cue */}
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10 text-xs font-mono text-slate-300">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
<span>{instruction}</span>
</div>
</div>
{/* Center Stage: Canvas + Timecode Overlay */}
<div
ref={stageRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
className="relative flex-1 w-full max-w-5xl mx-auto flex items-center justify-center cursor-grab active:cursor-grabbing select-none"
style={{ touchAction: 'none' }}
>
{/* Stage Ambient Glow */}
<div
className="absolute inset-0 pointer-events-none rounded-full blur-3xl opacity-20 transform scale-75"
style={{
background: `radial-gradient(circle, ${accentColor} 0%, rgba(0,0,0,0) 70%)`,
}}
/>
{/* Video Canvas */}
<canvas
ref={canvasRef}
className="relative z-10 w-full h-[65vh] max-h-[580px] object-contain rounded-xl shadow-2xl"
/>
{/* Stage Timecode Pill */}
<div className="absolute left-4 sm:left-8 bottom-6 z-20 px-3 py-1.5 rounded-lg bg-black/70 backdrop-blur-md border border-white/10 text-[11px] font-mono text-slate-300 flex items-center gap-2 pointer-events-none">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
<span>{formattedTimecode}</span>
</div>
{/* Drag Tip Overlay */}
{!isDragging && !isPlaying && scrollProgress < 0.02 && (
<div className="absolute bottom-6 right-4 sm:right-8 z-20 px-3.5 py-1.5 rounded-lg bg-black/70 backdrop-blur-md border border-white/10 text-xs font-mono text-slate-300 flex items-center gap-2 pointer-events-none animate-pulse">
<Maximize2 className="w-3.5 h-3.5" style={{ color: accentColor }} />
<span>ลากเพื่อ Scrub เฟรม</span>
</div>
)}
</div>
{/* Bottom Scrub Controls Bar */}
<div className="w-full max-w-7xl mx-auto flex flex-col gap-3 z-20">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Play/Pause Button */}
<div className="flex items-center gap-4 w-full sm:w-auto">
<button
type="button"
onClick={togglePlay}
className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 border border-white/15 text-xs font-mono flex items-center gap-2 transition-all cursor-pointer shrink-0"
>
{isPlaying ? (
<>
<Pause className="w-3.5 h-3.5 fill-current" />
<span>หยุดวิดีโอ (Pause)</span>
</>
) : (
<>
<Play className="w-3.5 h-3.5 fill-current" />
<span>เล่นวิดีโอ (Play)</span>
</>
)}
</button>
{/* Range Scrubber Slider */}
<input
type="range"
min={0}
max={totalFrames - 1}
value={currentFrame}
onChange={(e) => {
if (stateRef.current.playing) {
stateRef.current.playing = false
setIsPlaying(false)
if (stateRef.current.playTimer) clearInterval(stateRef.current.playTimer)
}
showFrame(Number(e.target.value))
}}
className="w-full sm:w-80 h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-[#d5ff63]"
style={{ accentColor }}
/>
</div>
{/* Frame Counter Display */}
<div className="text-xs font-mono text-slate-400 shrink-0">
<span className="font-bold text-white text-sm" style={{ color: accentColor }}>
{String(currentFrame + 1).padStart(padding, '0')}
</span>
<span className="mx-1.5 text-slate-600">/</span>
<span>{totalFrames} FRAMES</span>
</div>
</div>
{/* Scroll Progress Bar at very bottom */}
<div className="w-full h-1 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full transition-all duration-75"
style={{
width: `${scrollProgress * 100}%`,
backgroundColor: accentColor,
}}
/>
</div>
</div>
</div>
</div>
{/* ── Final Conversion CTA Banner ── */}
<div className="relative py-20 px-6 sm:px-12 max-w-6xl mx-auto border-t border-white/10">
<div className="p-8 sm:p-12 rounded-3xl bg-gradient-to-r from-white/[0.05] via-white/[0.02] to-transparent border border-white/10 flex flex-col sm:flex-row items-center justify-between gap-6">
<div className="space-y-1 text-center sm:text-left">
<span className="text-[10px] font-mono tracking-widest uppercase" style={{ color: accentColor }}>
FRAME BY FRAME PRECISION
</span>
<h4 className="text-2xl sm:text-3xl font-extrabold tracking-tight">
{endCtaTitle}
</h4>
</div>
<button
type="button"
onClick={handleCtaClick}
className="px-8 py-3.5 rounded-full font-bold text-sm tracking-wide text-black hover:opacity-90 active:scale-95 transition-all shadow-xl flex items-center gap-2 cursor-pointer shrink-0"
style={{ backgroundColor: accentColor }}
>
<span>{endCtaButtonText}</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
</section>
)
}
@@ -19,6 +19,7 @@ import PageDivider from '../PageDivider'
import CinematicStoryScroll from '../CinematicStoryScroll'
import AppleWatchShowcase from '../AppleWatchShowcase'
import Interactive360Showcase from '../Interactive360Showcase'
import VdoFrameScrubber from '../VdoFrameScrubber'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -2840,7 +2841,36 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
)
}
case 'INTERACTIVE_360_SHOWCASE':
case 'VDO_FRAME_SCRUBBER':
case 'INTERACTIVE_VDO_CANVAS':
case 'VIDEO_SCRUBBER': {
return (
<div key={section.id} className="w-full">
<VdoFrameScrubber
folderPath={(payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'}
pattern={(payload.pattern as string) || 'frame-{index}.webp'}
padding={Number(payload.padding) || 3}
totalFrames={Number(payload.totalFrames) || 432}
fps={Number(payload.fps) || 24}
durationSeconds={Number(payload.durationSeconds) || 18}
eyebrow={payload.eyebrow as string | undefined}
title={payload.title as string | undefined}
subtitle={payload.subtitle as string | undefined}
instruction={payload.instruction as string | undefined}
productName={payload.productName as string | undefined}
productBadge={payload.productBadge as string | undefined}
endCtaTitle={payload.endCtaTitle as string | undefined}
endCtaButtonText={payload.endCtaButtonText as string | undefined}
endCtaLink={payload.endCtaLink as string | undefined}
theme={(payload.theme as 'dark' | 'light') || 'dark'}
accentColor={(payload.accentColor as string) || '#d5ff63'}
lang={lang}
/>
</div>
)
}
case 'INTERACTIVE_360_SHOWCASE':
case 'TURNTABLE_3D_SHOWCASE':
case 'TURNTABLE_360': {
return (