first successful export
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
|
./aroll
|
||||||
24
DockerFile
Normal file
24
DockerFile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Go binary
|
||||||
|
FROM golang:1.25.7-alpine AS go-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=go-builder /app/server .
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./server"]
|
||||||
24
docker-compose.yaml
Normal file
24
docker-compose.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
command: sh -c "npm install && npm run dev -- --host"
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: DockerFile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- aroll_tmp:/tmp/aroll-uploads
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aroll_tmp:
|
||||||
@@ -5,11 +5,11 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/upload': 'http://localhost:8080',
|
'/upload': { target: 'http://app:8080', changeOrigin: true },
|
||||||
'/analyze': 'http://localhost:8080',
|
'/analyze': { target: 'http://app:8080', changeOrigin: true },
|
||||||
'/export': 'http://localhost:8080',
|
'/export': { target: 'http://app:8080', changeOrigin: true },
|
||||||
'/download': 'http://localhost:8080',
|
'/download': { target: 'http://app:8080', changeOrigin: true },
|
||||||
'/ws': { target: 'ws://localhost:8080', ws: true },
|
'/ws': { target: 'ws://app:8080', ws: true, changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -3,10 +3,3 @@ module aroll
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require github.com/gorilla/websocket v1.5.3
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
|
||||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,10 +1,2 @@
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
|
||||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"aroll/store"
|
"aroll/store"
|
||||||
"aroll/transcode"
|
"aroll/transcode"
|
||||||
@@ -19,8 +16,6 @@ type analyzeRequest struct {
|
|||||||
Padding float64 `json:"padding"`
|
Padding float64 `json:"padding"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeHandler fetches the video bytes from Redis, writes them to a short-lived
|
|
||||||
// temp file for FFmpeg, runs silence detection, then deletes the temp file.
|
|
||||||
func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -34,33 +29,15 @@ func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := st.Get(context.Background(), req.Filename)
|
path, ok := st.Path(req.Filename)
|
||||||
if err != nil {
|
if !ok {
|
||||||
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(req.Filename)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Write to a temp file — FFmpeg needs a file path, not a byte slice.
|
_, err := transcode.DetectSpeechSegments(
|
||||||
// This file exists only for the duration of the FFmpeg scan.
|
path,
|
||||||
tmp, err := os.CreateTemp("", "aroll-analyze-*"+ext)
|
|
||||||
if err != nil {
|
|
||||||
broadcastError(hub, "create temp: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer os.Remove(tmp.Name())
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
broadcastError(hub, "write temp: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tmp.Close()
|
|
||||||
|
|
||||||
_, err = transcode.DetectSpeechSegments(
|
|
||||||
tmp.Name(),
|
|
||||||
req.NoiseDb,
|
req.NoiseDb,
|
||||||
req.MinSilence,
|
req.MinSilence,
|
||||||
req.Padding,
|
req.Padding,
|
||||||
|
|||||||
@@ -1,40 +1,38 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"aroll/store"
|
"aroll/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadHandler fetches the exported video from Redis, streams it to the
|
|
||||||
// browser, then immediately deletes the key. One download per export.
|
|
||||||
func DownloadHandler(st *store.Store) http.HandlerFunc {
|
func DownloadHandler(st *store.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
key := strings.TrimPrefix(r.URL.Path, "/")
|
key := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
|
||||||
// Only serve keys we created in ExportHandler
|
|
||||||
if !strings.HasPrefix(key, "output-") {
|
if !strings.HasPrefix(key, "output-") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
path, ok := st.Path(key)
|
||||||
|
if !ok {
|
||||||
data, err := st.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "file not found or already downloaded", http.StatusNotFound)
|
http.Error(w, "file not found or already downloaded", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer st.Delete(key)
|
||||||
|
|
||||||
// Delete from Redis now — one download only
|
f, err := os.Open(path)
|
||||||
defer st.Delete(ctx, key)
|
if err != nil {
|
||||||
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "video/mp4")
|
fi, _ := f.Stat()
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="aroll-cut.mp4"`)
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
http.ServeContent(w, r, "aroll-cut.mp4", fi.ModTime(), f)
|
||||||
w.Write(data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"aroll/store"
|
"aroll/store"
|
||||||
"aroll/transcode"
|
"aroll/transcode"
|
||||||
@@ -17,9 +15,6 @@ type exportRequest struct {
|
|||||||
Segments []transcode.Segment `json:"segments"`
|
Segments []transcode.Segment `json:"segments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportHandler fetches the input video from Redis, runs FFmpeg to cut the
|
|
||||||
// silence, stores the output back in Redis, then cleans up the temp files.
|
|
||||||
// The output key is sent to the frontend via a "done" WebSocket message.
|
|
||||||
func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -37,61 +32,23 @@ func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := st.Get(context.Background(), req.Filename)
|
inPath, ok := st.Path(req.Filename)
|
||||||
if err != nil {
|
if !ok {
|
||||||
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(req.Filename)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Write input bytes to a temp file for FFmpeg
|
/* ToDo: Make sure that other video formats gets transcoded */
|
||||||
inTmp, err := os.CreateTemp("", "aroll-in-*"+ext)
|
outputKey := "output-" + store.NewID() + ".mp4"
|
||||||
if err != nil {
|
outPath := st.Allocate(outputKey)
|
||||||
broadcastError(hub, "create input temp: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer os.Remove(inTmp.Name())
|
|
||||||
|
|
||||||
if _, err := inTmp.Write(data); err != nil {
|
if err := transcode.ExportSegments(inPath, outPath, req.Segments, hub.Broadcast); err != nil {
|
||||||
inTmp.Close()
|
os.Remove(outPath)
|
||||||
broadcastError(hub, "write input temp: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inTmp.Close()
|
|
||||||
|
|
||||||
// Create an output temp file for FFmpeg to write into
|
|
||||||
outTmp, err := os.CreateTemp("", "aroll-out-*.mp4")
|
|
||||||
if err != nil {
|
|
||||||
broadcastError(hub, "create output temp: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outTmp.Close()
|
|
||||||
defer os.Remove(outTmp.Name())
|
|
||||||
|
|
||||||
// Run FFmpeg — progress streamed via WebSocket
|
|
||||||
if err := transcode.ExportSegments(
|
|
||||||
inTmp.Name(), outTmp.Name(), req.Segments, hub.Broadcast,
|
|
||||||
); err != nil {
|
|
||||||
broadcastError(hub, err.Error())
|
broadcastError(hub, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the output bytes and store them in Redis
|
|
||||||
outData, err := os.ReadFile(outTmp.Name())
|
|
||||||
if err != nil {
|
|
||||||
broadcastError(hub, "read output: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
outputKey := "output-" + store.NewID()
|
|
||||||
if err := st.Set(context.Background(), outputKey, outData); err != nil {
|
|
||||||
broadcastError(hub, "store output: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell the frontend where to download from
|
|
||||||
msg, _ := json.Marshal(map[string]string{
|
msg, _ := json.Marshal(map[string]string{
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"message": outputKey,
|
"message": outputKey,
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"aroll/store"
|
"aroll/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadHandler reads the video into memory and stores it in Redis under a
|
|
||||||
// random key. The key is returned to the frontend as "filename".
|
|
||||||
// Nothing is written to disk — the file lives only in Redis with a TTL.
|
|
||||||
func UploadHandler(st *store.Store) http.HandlerFunc {
|
func UploadHandler(st *store.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -21,10 +16,11 @@ func UploadHandler(st *store.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<30)
|
r.Body = http.MaxBytesReader(w, r.Body, 4<<30)
|
||||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
http.Error(w, "parse form: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "parse form: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
|
||||||
file, header, err := r.FormFile("video")
|
file, header, err := r.FormFile("video")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,22 +29,14 @@ func UploadHandler(st *store.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
data, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "read file: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := filepath.Ext(header.Filename)
|
ext := filepath.Ext(header.Filename)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = ".mp4"
|
ext = ".mp4"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key format: "video-<id><ext>" e.g. "video-a3f9...2b.mp4"
|
key, err := st.Save(file, ext)
|
||||||
key := "video-" + store.NewID() + ext
|
if err != nil {
|
||||||
|
http.Error(w, "save: "+err.Error(), http.StatusInternalServerError)
|
||||||
if err := st.Set(context.Background(), key, data); err != nil {
|
|
||||||
http.Error(w, "store: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
main.go
12
main.go
@@ -11,16 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
redisAddr := "localhost:6379"
|
st, err := store.New()
|
||||||
if addr := os.Getenv("REDIS_ADDR"); addr != "" {
|
|
||||||
redisAddr = addr
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := store.New(redisAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("redis: %v", err)
|
log.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("Connected to Redis at %s", redisAddr)
|
log.Println("Temp storage ready")
|
||||||
|
|
||||||
hub := ws.NewHub()
|
hub := ws.NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
@@ -33,7 +28,6 @@ func main() {
|
|||||||
mux.HandleFunc("/export", handlers.ExportHandler(st, hub))
|
mux.HandleFunc("/export", handlers.ExportHandler(st, hub))
|
||||||
mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st)))
|
mux.Handle("/download/", http.StripPrefix("/download/", handlers.DownloadHandler(st)))
|
||||||
|
|
||||||
// Serve the React build in production
|
|
||||||
if _, err := os.Stat("frontend/dist"); err == nil {
|
if _, err := os.Stat("frontend/dist"); err == nil {
|
||||||
mux.Handle("/", http.FileServer(http.Dir("frontend/dist")))
|
mux.Handle("/", http.FileServer(http.Dir("frontend/dist")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,62 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"io"
|
||||||
|
"os"
|
||||||
"github.com/redis/go-redis/v9"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TTL is how long any stored file lives in Redis before automatic deletion.
|
|
||||||
const TTL = 2 * time.Hour
|
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
rdb *redis.Client
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(addr string) (*Store, error) {
|
func New() (*Store, error) {
|
||||||
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
dir := filepath.Join(os.TempDir(), "aroll-uploads")
|
||||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("redis connect %s: %w", addr, err)
|
return nil, fmt.Errorf("create upload dir: %w", err)
|
||||||
}
|
}
|
||||||
return &Store{rdb: rdb}, nil
|
return &Store{dir: dir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set stores binary data under key with the global TTL.
|
// Save streams r into a new file in the store and returns its key.
|
||||||
func (s *Store) Set(ctx context.Context, key string, data []byte) error {
|
func (s *Store) Save(r io.Reader, ext string) (string, error) {
|
||||||
return s.rdb.Set(ctx, key, data, TTL).Err()
|
key := "video-" + NewID() + ext
|
||||||
|
path := filepath.Join(s.dir, key)
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(f, r); err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
return "", fmt.Errorf("write file: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves binary data by key.
|
// Path returns the absolute path for a key, if the file exists.
|
||||||
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
func (s *Store) Path(key string) (string, bool) {
|
||||||
return s.rdb.Get(ctx, key).Bytes()
|
path := filepath.Join(s.dir, key)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return path, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a key immediately (called after download).
|
// Allocate returns a path for a new key without creating the file.
|
||||||
func (s *Store) Delete(ctx context.Context, key string) {
|
// Used by ExportHandler so ffmpeg can write directly to the store.
|
||||||
s.rdb.Del(ctx, key)
|
func (s *Store) Allocate(key string) string {
|
||||||
|
return filepath.Join(s.dir, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the file for a key.
|
||||||
|
func (s *Store) Delete(key string) {
|
||||||
|
os.Remove(filepath.Join(s.dir, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewID generates a random hex ID for use as a Redis key suffix.
|
|
||||||
func NewID() string {
|
func NewID() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Segment is a time interval [Start, End) in seconds.
|
// Segment is a time interval [Start, End) in seconds.
|
||||||
@@ -24,8 +25,8 @@ type wsMsg struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(msg wsMsg, broadcast func([]byte)) {
|
func send(msg *wsMsg, broadcast func([]byte)) {
|
||||||
data, _ := json.Marshal(msg)
|
data, _ := json.Marshal(&msg)
|
||||||
broadcast(data)
|
broadcast(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +37,7 @@ func send(msg wsMsg, broadcast func([]byte)) {
|
|||||||
// DetectSpeechSegments runs ffmpeg's silencedetect filter and returns the
|
// DetectSpeechSegments runs ffmpeg's silencedetect filter and returns the
|
||||||
// non-silent (speech) segments. A "segments" WebSocket message is broadcast
|
// non-silent (speech) segments. A "segments" WebSocket message is broadcast
|
||||||
// when detection finishes.
|
// when detection finishes.
|
||||||
func DetectSpeechSegments(
|
func DetectSpeechSegments(inputPath string, noiseDb, minDuration, padding float64, broadcast func([]byte)) ([]Segment, error) {
|
||||||
inputPath string,
|
|
||||||
noiseDb, minDuration, padding float64,
|
|
||||||
broadcast func([]byte),
|
|
||||||
) ([]Segment, error) {
|
|
||||||
filter := fmt.Sprintf("silencedetect=noise=%.0fdB:d=%.2f", noiseDb, minDuration)
|
filter := fmt.Sprintf("silencedetect=noise=%.0fdB:d=%.2f", noiseDb, minDuration)
|
||||||
|
|
||||||
cmd := exec.Command("ffmpeg",
|
cmd := exec.Command("ffmpeg",
|
||||||
@@ -91,9 +88,9 @@ func DetectSpeechSegments(
|
|||||||
return nil, fmt.Errorf("ffmpeg: %w", err)
|
return nil, fmt.Errorf("ffmpeg: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
segments := invertSilences(silences, totalDuration, padding)
|
segments := removeSilence(silences, totalDuration, padding)
|
||||||
|
|
||||||
send(wsMsg{
|
send(&wsMsg{
|
||||||
Type: "segments",
|
Type: "segments",
|
||||||
Segments: segments,
|
Segments: segments,
|
||||||
Duration: totalDuration,
|
Duration: totalDuration,
|
||||||
@@ -104,9 +101,9 @@ func DetectSpeechSegments(
|
|||||||
|
|
||||||
type silenceInterval struct{ start, end float64 }
|
type silenceInterval struct{ start, end float64 }
|
||||||
|
|
||||||
// invertSilences turns silence regions into speech regions, with a small
|
// removeSilence turns silence regions into speech regions, with a small
|
||||||
// padding buffer so words at the edges don't get clipped.
|
// padding buffer so words at the edges don't get clipped.
|
||||||
func invertSilences(silences []silenceInterval, totalDuration, padding float64) []Segment {
|
func removeSilence(silences []silenceInterval, totalDuration, padding float64) []Segment {
|
||||||
if len(silences) == 0 {
|
if len(silences) == 0 {
|
||||||
return []Segment{{Start: 0, End: totalDuration}}
|
return []Segment{{Start: 0, End: totalDuration}}
|
||||||
}
|
}
|
||||||
@@ -136,37 +133,41 @@ func invertSilences(silences []silenceInterval, totalDuration, padding float64)
|
|||||||
return segments
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Export
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ExportSegments concatenates the given segments using ffmpeg's concat demuxer
|
|
||||||
// (stream-copy, no re-encode). Progress is streamed via broadcast.
|
|
||||||
func ExportSegments(
|
func ExportSegments(
|
||||||
inputPath, outputPath string,
|
inputPath, outputPath string,
|
||||||
segments []Segment,
|
segments []Segment,
|
||||||
broadcast func([]byte),
|
broadcast func([]byte),
|
||||||
) error {
|
) error {
|
||||||
concatPath := outputPath + ".concat.txt"
|
|
||||||
f, err := os.Create(concatPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create concat file: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(concatPath)
|
|
||||||
|
|
||||||
totalDuration := 0.0
|
totalDuration := 0.0
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
totalDuration += seg.End - seg.Start
|
totalDuration += seg.End - seg.Start
|
||||||
fmt.Fprintf(f, "file '%s'\ninpoint %.6f\noutpoint %.6f\n",
|
|
||||||
inputPath, seg.Start, seg.End)
|
|
||||||
}
|
}
|
||||||
f.Close()
|
|
||||||
|
// Build a filter_complex that trims each segment and resets timestamps,
|
||||||
|
// then concatenates them. This avoids non-monotonic DTS issues that occur
|
||||||
|
// when stream-copying segments with their original timestamps.
|
||||||
|
var filterParts []string
|
||||||
|
var concatInputs string
|
||||||
|
for i, seg := range segments {
|
||||||
|
filterParts = append(filterParts,
|
||||||
|
fmt.Sprintf("[0:v]trim=start=%.6f:end=%.6f,setpts=PTS-STARTPTS[v%d]", seg.Start, seg.End, i),
|
||||||
|
fmt.Sprintf("[0:a]atrim=start=%.6f:end=%.6f,asetpts=PTS-STARTPTS[a%d]", seg.Start, seg.End, i),
|
||||||
|
)
|
||||||
|
concatInputs += fmt.Sprintf("[v%d][a%d]", i, i)
|
||||||
|
}
|
||||||
|
filterParts = append(filterParts,
|
||||||
|
fmt.Sprintf("%sconcat=n=%d:v=1:a=1[outv][outa]", concatInputs, len(segments)),
|
||||||
|
)
|
||||||
|
filterComplex := strings.Join(filterParts, ";")
|
||||||
|
|
||||||
cmd := exec.Command("ffmpeg",
|
cmd := exec.Command("ffmpeg",
|
||||||
"-f", "concat",
|
"-i", inputPath,
|
||||||
"-safe", "0",
|
"-filter_complex", filterComplex,
|
||||||
"-i", concatPath,
|
"-map", "[outv]",
|
||||||
"-c", "copy",
|
"-map", "[outa]",
|
||||||
|
// around 80% compression
|
||||||
|
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
|
||||||
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
"-progress", "pipe:1",
|
"-progress", "pipe:1",
|
||||||
"-y",
|
"-y",
|
||||||
outputPath,
|
outputPath,
|
||||||
@@ -194,7 +195,7 @@ func ExportSegments(
|
|||||||
if pct > 100 {
|
if pct > 100 {
|
||||||
pct = 100
|
pct = 100
|
||||||
}
|
}
|
||||||
send(wsMsg{Type: "progress", Percent: pct}, broadcast)
|
send(&wsMsg{Type: "progress", Percent: pct}, broadcast)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
// Client represents a single WebSocket connection.
|
// Client represents a single WebSocket connection.
|
||||||
// The handler owns the actual *websocket.Conn; the hub only needs the send channel.
|
// The handler owns the actual *websocket.Conn; the hub only needs the send channel.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -10,7 +8,6 @@ type Client struct {
|
|||||||
|
|
||||||
// Hub manages all active clients and routes broadcast messages to them.
|
// Hub manages all active clients and routes broadcast messages to them.
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.Mutex
|
|
||||||
clients map[*Client]struct{}
|
clients map[*Client]struct{}
|
||||||
Register chan *Client
|
Register chan *Client
|
||||||
Unregister chan *Client
|
Unregister chan *Client
|
||||||
@@ -30,20 +27,15 @@ func (h *Hub) Run() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case c := <-h.Register:
|
case c := <-h.Register:
|
||||||
h.mu.Lock()
|
|
||||||
h.clients[c] = struct{}{}
|
h.clients[c] = struct{}{}
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
case c := <-h.Unregister:
|
case c := <-h.Unregister:
|
||||||
h.mu.Lock()
|
|
||||||
if _, ok := h.clients[c]; ok {
|
if _, ok := h.clients[c]; ok {
|
||||||
delete(h.clients, c)
|
delete(h.clients, c)
|
||||||
close(c.Send)
|
close(c.Send)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
case msg := <-h.broadcast:
|
case msg := <-h.broadcast:
|
||||||
h.mu.Lock()
|
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
select {
|
select {
|
||||||
case c.Send <- msg:
|
case c.Send <- msg:
|
||||||
@@ -52,7 +44,6 @@ func (h *Hub) Run() {
|
|||||||
close(c.Send)
|
close(c.Send)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user