implamented Aroll cuter

This commit is contained in:
2026-02-25 23:27:31 -08:00
parent 2233d08fb5
commit 0a88623264
13 changed files with 277 additions and 4 deletions

40
frontend/dist/assets/index-B7wEvRdc.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A-Roll Cutter</title>
<script type="module" crossorigin src="/assets/index-B7wEvRdc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-b2ubsn.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,10 +1,12 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import type { Segment } from './Timeline'
const PX_PER_SEC = 80 // timeline scale: 80px = 1 second
const PX_PER_SEC = 80 // timeline scale: 80px = 1 second at 1× zoom
const THUMB_H = 60 // rendered thumbnail strip height
const CAP_W = 120 // canvas capture width
const CAP_H = 68 // canvas capture height (16:9 ≈ 120×68)
const MIN_ZOOM = 0.25
const MAX_ZOOM = 8
interface Props {
videoUrl: string
@@ -23,12 +25,18 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
const [currentTime, setCurrentTime] = useState(0)
const [playing, setPlaying] = useState(false)
const [scrubbing, setScrubbing] = useState(false)
const [zoom, setZoom] = useState(1)
// Get intrinsic duration from the video element itself so we can show thumbnails
// even before the server responds with the segments message.
const [intrinsicDuration, setIntrinsicDuration] = useState(duration)
const effectiveDuration = duration > 0 ? duration : intrinsicDuration
// Always-current refs so event handlers don't need to be re-registered on every render.
const trackWidthRef = useRef(1)
const effectiveDurationRef = useRef(effectiveDuration)
effectiveDurationRef.current = effectiveDuration
useEffect(() => {
const v = videoRef.current
if (!v) return
@@ -38,9 +46,13 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
return () => v.removeEventListener('loadedmetadata', onMeta)
}, [])
const trackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC), 1)
const thumbCount = Math.max(Math.ceil(trackWidth / 80), 1)
// thumbCount is fixed to the base (1×) track width so thumbnails are not
// re-extracted on every zoom change — only on video/duration changes.
const baseTrackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC), 1)
const thumbCount = Math.max(Math.ceil(baseTrackWidth / 80), 1)
const trackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC * zoom), 1)
const thumbW = trackWidth / thumbCount
trackWidthRef.current = trackWidth
// Extract frames client-side — no server round-trip needed for thumbnails
useEffect(() => {
@@ -113,6 +125,46 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
}
}, [currentTime, playing, effectiveDuration, trackWidth])
// After a zoom change, scroll so that a specific time stays at a specific screen offset.
// Set by both handleSliderZoom (keeps viewport center) and the wheel handler (keeps cursor).
const scrollAdjustRef = useRef<{ time: number; screenOffset: number } | null>(null)
useEffect(() => {
const el = scrollRef.current
const adj = scrollAdjustRef.current
if (!el || !adj || effectiveDuration === 0) return
scrollAdjustRef.current = null
el.scrollLeft = (adj.time / effectiveDuration) * trackWidthRef.current - adj.screenOffset
}, [zoom, effectiveDuration])
// Ctrl/Cmd+Wheel on the scroll area to zoom, keeping the cursor's time position fixed.
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const rect = el.getBoundingClientRect()
const offsetX = e.clientX - rect.left
const cursorTime = ((offsetX + el.scrollLeft) / trackWidthRef.current) * effectiveDurationRef.current
scrollAdjustRef.current = { time: cursorTime, screenOffset: offsetX }
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15
setZoom(prev => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, prev * factor)))
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleSliderZoom = (newZoom: number) => {
const el = scrollRef.current
if (el && effectiveDuration > 0) {
const centerX = el.scrollLeft + el.clientWidth / 2
const centerTime = (centerX / trackWidthRef.current) * effectiveDuration
scrollAdjustRef.current = { time: centerTime, screenOffset: el.clientWidth / 2 }
}
setZoom(newZoom)
}
// Map a clientX position to a video timestamp
const getTimeAt = useCallback((clientX: number): number => {
if (!trackRef.current || effectiveDuration === 0) return 0
@@ -178,6 +230,25 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
</div>
</div>
{/* ── Zoom control bar ── */}
<div className="vt-zoom-bar">
<span className="vt-zoom-label">Zoom</span>
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.05}
value={zoom}
onChange={e => handleSliderZoom(Number(e.target.value))}
className="vt-zoom-slider"
title="Ctrl+Wheel on the timeline also zooms"
/>
<span className="vt-zoom-value">{zoom.toFixed(1)}×</span>
{Math.abs(zoom - 1) > 0.01 && (
<button className="vt-zoom-reset" onClick={() => handleSliderZoom(1)}>Reset</button>
)}
</div>
{/* ── Scrollable timeline ── */}
<div ref={scrollRef} className="vt-scroll">
{/* Ruler sits inside scroll so it tracks with the track */}

View File

@@ -574,6 +574,58 @@ body {
font-family: ui-monospace, monospace;
}
/* ── Zoom control bar ── */
.vt-zoom-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.vt-zoom-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.vt-zoom-slider {
flex: 1;
height: 4px;
accent-color: var(--blue);
cursor: pointer;
}
.vt-zoom-value {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace;
min-width: 3.5ch;
text-align: right;
flex-shrink: 0;
}
.vt-zoom-reset {
background: none;
border: 1px solid var(--border-2);
border-radius: 4px;
color: var(--text-muted);
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
}
.vt-zoom-reset:hover {
background: var(--surface-2);
color: var(--text);
}
/* ── Scrollable area ── */
.vt-scroll {