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 { useRef, useState, useEffect, useCallback } from 'react'
import type { Segment } from './Timeline' 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 THUMB_H = 60 // rendered thumbnail strip height
const CAP_W = 120 // canvas capture width const CAP_W = 120 // canvas capture width
const CAP_H = 68 // canvas capture height (16:9 ≈ 120×68) const CAP_H = 68 // canvas capture height (16:9 ≈ 120×68)
const MIN_ZOOM = 0.25
const MAX_ZOOM = 8
interface Props { interface Props {
videoUrl: string videoUrl: string
@@ -23,12 +25,18 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [scrubbing, setScrubbing] = 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 // Get intrinsic duration from the video element itself so we can show thumbnails
// even before the server responds with the segments message. // even before the server responds with the segments message.
const [intrinsicDuration, setIntrinsicDuration] = useState(duration) const [intrinsicDuration, setIntrinsicDuration] = useState(duration)
const effectiveDuration = duration > 0 ? duration : intrinsicDuration 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(() => { useEffect(() => {
const v = videoRef.current const v = videoRef.current
if (!v) return if (!v) return
@@ -38,9 +46,13 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
return () => v.removeEventListener('loadedmetadata', onMeta) return () => v.removeEventListener('loadedmetadata', onMeta)
}, []) }, [])
const trackWidth = Math.max(Math.round(effectiveDuration * PX_PER_SEC), 1) // thumbCount is fixed to the base (1×) track width so thumbnails are not
const thumbCount = Math.max(Math.ceil(trackWidth / 80), 1) // 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 const thumbW = trackWidth / thumbCount
trackWidthRef.current = trackWidth
// Extract frames client-side — no server round-trip needed for thumbnails // Extract frames client-side — no server round-trip needed for thumbnails
useEffect(() => { useEffect(() => {
@@ -113,6 +125,46 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
} }
}, [currentTime, playing, effectiveDuration, trackWidth]) }, [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 // Map a clientX position to a video timestamp
const getTimeAt = useCallback((clientX: number): number => { const getTimeAt = useCallback((clientX: number): number => {
if (!trackRef.current || effectiveDuration === 0) return 0 if (!trackRef.current || effectiveDuration === 0) return 0
@@ -178,6 +230,25 @@ export default function VideoTimeline({ videoUrl, duration, segments, onToggle,
</div> </div>
</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 ── */} {/* ── Scrollable timeline ── */}
<div ref={scrollRef} className="vt-scroll"> <div ref={scrollRef} className="vt-scroll">
{/* Ruler sits inside scroll so it tracks with the track */} {/* Ruler sits inside scroll so it tracks with the track */}

View File

@@ -574,6 +574,58 @@ body {
font-family: ui-monospace, monospace; 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 ── */ /* ── Scrollable area ── */
.vt-scroll { .vt-scroll {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"aroll/store" "aroll/store"
@@ -49,6 +50,8 @@ func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
}() }()
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
log.Println("Analyze handler works")
} }
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -34,5 +35,7 @@ func DownloadHandler(st *store.Store) http.HandlerFunc {
fi, _ := f.Stat() fi, _ := f.Stat()
w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`) w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`)
http.ServeContent(w, r, "aroll-cut.mp4", fi.ModTime(), f) http.ServeContent(w, r, "aroll-cut.mp4", fi.ModTime(), f)
log.Println("Download handler works")
} }
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"os" "os"
@@ -57,5 +58,7 @@ func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
}() }()
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
log.Println("Export handler works")
} }
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"path/filepath" "path/filepath"
@@ -42,5 +43,7 @@ func UploadHandler(st *store.Store) http.HandlerFunc {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"filename": key}) json.NewEncoder(w).Encode(map[string]string{"filename": key})
log.Println("Upload handler works")
} }
} }

38
handlers/zoom.go Normal file
View File

@@ -0,0 +1,38 @@
package handlers
import (
"encoding/json"
"net/http"
"aroll/transcode"
"aroll/ws"
)
type zoomRequest struct {
Zoom float64 `json:"zoom"` // 1100, 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)
}
}

View File

@@ -26,6 +26,7 @@ func main() {
mux.HandleFunc("/upload", handlers.UploadHandler(st)) mux.HandleFunc("/upload", handlers.UploadHandler(st))
mux.HandleFunc("/analyze", handlers.AnalyzeHandler(st, hub)) mux.HandleFunc("/analyze", handlers.AnalyzeHandler(st, hub))
mux.HandleFunc("/export", handlers.ExportHandler(st, hub)) mux.HandleFunc("/export", handlers.ExportHandler(st, hub))
mux.HandleFunc("/zoom", handlers.ZoomHandler(hub))
mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st))) mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st)))
if _, err := os.Stat("frontend/dist"); err == nil { if _, err := os.Stat("frontend/dist"); err == nil {

View File

@@ -63,7 +63,7 @@ func ExportSegments(
"-map", "[outv]", "-map", "[outv]",
"-map", "[outa]", "-map", "[outa]",
// around 80% compression // around 80% compression
"-c:v", "libx264", "-crf", "28", "-preset", "fast", "-c:v", "libx264", "-crf", "28", "-preset", "veryfast",
"-c:a", "aac", "-b:a", "128k", "-c:a", "aac", "-b:a", "128k",
"-progress", "pipe:1", "-progress", "pipe:1",
"-y", "-y",

45
transcode/timeline.go Normal file
View 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
// (1100, 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,
}
}