implamented Aroll cuter
This commit is contained in:
40
frontend/dist/assets/index-B7wEvRdc.js
vendored
Normal file
40
frontend/dist/assets/index-B7wEvRdc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-C-b2ubsn.css
vendored
Normal file
1
frontend/dist/assets/index-C-b2ubsn.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal 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>
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user