first successful export

This commit is contained in:
2026-02-25 00:27:36 -08:00
parent b70ea7e877
commit 68ccc2f4fc
14 changed files with 163 additions and 207 deletions

View File

@@ -1,11 +1,8 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"aroll/store"
"aroll/transcode"
@@ -19,8 +16,6 @@ type analyzeRequest struct {
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 {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -34,33 +29,15 @@ func AnalyzeHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
return
}
data, err := st.Get(context.Background(), req.Filename)
if err != nil {
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
path, ok := st.Path(req.Filename)
if !ok {
http.Error(w, "file not found", http.StatusNotFound)
return
}
ext := filepath.Ext(req.Filename)
go func() {
// Write to a temp file — FFmpeg needs a file path, not a byte slice.
// This file exists only for the duration of the FFmpeg scan.
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(),
_, err := transcode.DetectSpeechSegments(
path,
req.NoiseDb,
req.MinSilence,
req.Padding,

View File

@@ -1,40 +1,38 @@
package handlers
import (
"context"
"net/http"
"strconv"
"os"
"strings"
"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 {
return func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/")
// Only serve keys we created in ExportHandler
if !strings.HasPrefix(key, "output-") {
http.NotFound(w, r)
return
}
ctx := context.Background()
data, err := st.Get(ctx, key)
if err != nil {
path, ok := st.Path(key)
if !ok {
http.Error(w, "file not found or already downloaded", http.StatusNotFound)
return
}
defer st.Delete(key)
// Delete from Redis now — one download only
defer st.Delete(ctx, key)
f, err := os.Open(path)
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-Length", strconv.Itoa(len(data)))
w.Write(data)
http.ServeContent(w, r, "aroll-cut.mp4", fi.ModTime(), f)
}
}

View File

@@ -1,11 +1,9 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"aroll/store"
"aroll/transcode"
@@ -17,9 +15,6 @@ type exportRequest struct {
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 {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -37,61 +32,23 @@ func ExportHandler(st *store.Store, hub *ws.Hub) http.HandlerFunc {
return
}
data, err := st.Get(context.Background(), req.Filename)
if err != nil {
http.Error(w, "file not found in store (may have expired)", http.StatusNotFound)
inPath, ok := st.Path(req.Filename)
if !ok {
http.Error(w, "file not found", http.StatusNotFound)
return
}
ext := filepath.Ext(req.Filename)
go func() {
// Write input bytes to a temp file for FFmpeg
inTmp, err := os.CreateTemp("", "aroll-in-*"+ext)
if err != nil {
broadcastError(hub, "create input temp: "+err.Error())
return
}
defer os.Remove(inTmp.Name())
/* ToDo: Make sure that other video formats gets transcoded */
outputKey := "output-" + store.NewID() + ".mp4"
outPath := st.Allocate(outputKey)
if _, err := inTmp.Write(data); err != nil {
inTmp.Close()
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 {
if err := transcode.ExportSegments(inPath, outPath, req.Segments, hub.Broadcast); err != nil {
os.Remove(outPath)
broadcastError(hub, err.Error())
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{
"type": "done",
"message": outputKey,

View File

@@ -1,18 +1,13 @@
package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
"path/filepath"
"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 {
return func(w http.ResponseWriter, r *http.Request) {
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)
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)
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("video")
if err != nil {
@@ -33,22 +29,14 @@ func UploadHandler(st *store.Store) http.HandlerFunc {
}
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)
if ext == "" {
ext = ".mp4"
}
// Key format: "video-<id><ext>" e.g. "video-a3f9...2b.mp4"
key := "video-" + store.NewID() + ext
if err := st.Set(context.Background(), key, data); err != nil {
http.Error(w, "store: "+err.Error(), http.StatusInternalServerError)
key, err := st.Save(file, ext)
if err != nil {
http.Error(w, "save: "+err.Error(), http.StatusInternalServerError)
return
}