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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"aroll/store"
|
||||
@@ -49,6 +50,8 @@ func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
|
||||
log.Println("Analyze handler works")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -34,5 +35,7 @@ func DownloadHandler(st *store.Store) http.HandlerFunc {
|
||||
fi, _ := f.Stat()
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`)
|
||||
http.ServeContent(w, r, "aroll-cut.mp4", fi.ModTime(), f)
|
||||
|
||||
log.Println("Download handler works")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -57,5 +58,7 @@ func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
|
||||
log.Println("Export handler works")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
@@ -42,5 +43,7 @@ func UploadHandler(st *store.Store) http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"filename": key})
|
||||
|
||||
log.Println("Upload handler works")
|
||||
}
|
||||
}
|
||||
|
||||
38
handlers/zoom.go
Normal file
38
handlers/zoom.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"aroll/transcode"
|
||||
"aroll/ws"
|
||||
)
|
||||
|
||||
type zoomRequest struct {
|
||||
Zoom float64 `json:"zoom"` // 1–100, percentage of total duration to show
|
||||
Center float64 `json:"center"` // scroll center in seconds
|
||||
Duration float64 `json:"duration"` // total video duration in seconds
|
||||
}
|
||||
|
||||
func ZoomHandler(hub *ws.Hub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req zoomRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h := transcode.HandlerZoom{Center: req.Center}
|
||||
result := h.TimelineZoom(req.Zoom, req.Duration)
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
hub.Broadcast(data)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
1
main.go
1
main.go
@@ -26,6 +26,7 @@ func main() {
|
||||
mux.HandleFunc("/upload", handlers.UploadHandler(st))
|
||||
mux.HandleFunc("/analyze", handlers.AnalyzeHandler(st, hub))
|
||||
mux.HandleFunc("/export", handlers.ExportHandler(st, hub))
|
||||
mux.HandleFunc("/zoom", handlers.ZoomHandler(hub))
|
||||
mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st)))
|
||||
|
||||
if _, err := os.Stat("frontend/dist"); err == nil {
|
||||
|
||||
@@ -63,7 +63,7 @@ func ExportSegments(
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
// around 80% compression
|
||||
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
|
||||
"-c:v", "libx264", "-crf", "28", "-preset", "veryfast",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-progress", "pipe:1",
|
||||
"-y",
|
||||
45
transcode/timeline.go
Normal file
45
transcode/timeline.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package transcode
|
||||
|
||||
// HandlerZoom holds the current scroll center for timeline zoom calculations.
|
||||
type HandlerZoom struct {
|
||||
Center float64 // center of the visible window in seconds
|
||||
}
|
||||
|
||||
// ZoomResult is the result of a TimelineZoom calculation.
|
||||
type ZoomResult struct {
|
||||
Type string `json:"type"`
|
||||
Percent float64 `json:"percent"`
|
||||
ViewStart float64 `json:"viewStart"`
|
||||
ViewEnd float64 `json:"viewEnd"`
|
||||
}
|
||||
|
||||
// TimelineZoom computes the visible time window for the given zoomPercentage
|
||||
// (1–100, where 100 = full duration visible) centered on h.Center.
|
||||
func (h *HandlerZoom) TimelineZoom(zoomPercentage, duration float64) *ZoomResult {
|
||||
visibleDuration := duration * (zoomPercentage / 100)
|
||||
half := visibleDuration / 2
|
||||
|
||||
viewStart := h.Center - half
|
||||
viewEnd := h.Center + half
|
||||
|
||||
// Clamp to [0, duration], shifting the window rather than just truncating
|
||||
// so the visible span stays the same size when near the edges.
|
||||
if viewStart < 0 {
|
||||
viewEnd -= viewStart
|
||||
viewStart = 0
|
||||
}
|
||||
if viewEnd > duration {
|
||||
viewStart -= viewEnd - duration
|
||||
viewEnd = duration
|
||||
}
|
||||
if viewStart < 0 {
|
||||
viewStart = 0
|
||||
}
|
||||
|
||||
return &ZoomResult{
|
||||
Type: "zoom",
|
||||
Percent: zoomPercentage,
|
||||
ViewStart: viewStart,
|
||||
ViewEnd: viewEnd,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user