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 { 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 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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("/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 {
|
||||||
|
|||||||
@@ -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
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