// backend\split.go package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" ) type splitVideoRequest struct { File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4" Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0] } type splitVideoSegmentResponse struct { Index int `json:"index"` Start float64 `json:"start"` End float64 `json:"end"` Duration float64 `json:"duration"` File string `json:"file"` Path string `json:"path"` } type splitVideoResponse struct { OK bool `json:"ok"` File string `json:"file"` Source string `json:"source"` Segments []splitVideoSegmentResponse `json:"segments"` } func recordSplitVideo(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodPost) { return } var req splitVideoRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungültiger JSON-Body: "+err.Error(), http.StatusBadRequest) return } req.File = strings.TrimSpace(req.File) if req.File == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } if !isAllowedVideoExt(req.File) { http.Error(w, "nur .mp4 oder .ts erlaubt", http.StatusBadRequest) return } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(doneAbs) == "" { http.Error(w, "doneDir ist leer", http.StatusBadRequest) return } srcPath, _, fi, err := resolveDoneFileByName(doneAbs, req.File) if err != nil { http.Error(w, "quelldatei nicht gefunden", http.StatusNotFound) return } if fi == nil || fi.IsDir() || fi.Size() <= 0 { http.Error(w, "quelldatei ungültig", http.StatusBadRequest) return } srcPath = filepath.Clean(srcPath) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() durationSec, err := durationSecondsCached(ctx, srcPath) if err != nil || durationSec <= 0 { http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError) return } points, err := normalizeSplitPoints(req.Splits, durationSec) if err != nil { http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest) return } if len(points) == 0 { http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest) return } segments := buildSplitSegments(points, durationSec) if len(segments) < 2 { http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest) return } outDir := filepath.Join(filepath.Dir(srcPath), "_split") if err := os.MkdirAll(outDir, 0o755); err != nil { http.Error(w, "zielordner konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError) return } base := strings.TrimSuffix(filepath.Base(srcPath), filepath.Ext(srcPath)) ext := strings.ToLower(filepath.Ext(srcPath)) if ext == "" { ext = ".mp4" } resp := splitVideoResponse{ OK: true, File: req.File, Source: srcPath, } for i, seg := range segments { outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext) outPath := filepath.Join(outDir, outName) if err := splitSingleSegment(r.Context(), srcPath, outPath, seg.Start, seg.Duration); err != nil { http.Error( w, fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), http.StatusInternalServerError, ) return } resp.Segments = append(resp.Segments, splitVideoSegmentResponse{ Index: i + 1, Start: seg.Start, End: seg.End, Duration: seg.Duration, File: outName, Path: outPath, }) } notifyDoneChanged() respondJSON(w, resp) } type normalizedSegment struct { Start float64 End float64 Duration float64 } func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { if duration <= 0 { return nil, fmt.Errorf("duration <= 0") } out := make([]float64, 0, len(raw)) for _, v := range raw { if v <= 0 { continue } if v >= duration { continue } out = append(out, v) } if len(out) == 0 { return nil, fmt.Errorf("alle split-punkte liegen außerhalb der videodauer") } sort.Float64s(out) dedup := make([]float64, 0, len(out)) for _, v := range out { if len(dedup) == 0 || absFloat(dedup[len(dedup)-1]-v) >= 0.20 { dedup = append(dedup, v) } } if len(dedup) == 0 { return nil, fmt.Errorf("keine eindeutigen split-punkte übrig") } return dedup, nil } func buildSplitSegments(points []float64, duration float64) []normalizedSegment { all := make([]float64, 0, len(points)+2) all = append(all, 0) all = append(all, points...) all = append(all, duration) out := make([]normalizedSegment, 0, len(all)-1) for i := 0; i < len(all)-1; i++ { start := all[i] end := all[i+1] dur := end - start if dur <= 0.10 { continue } out = append(out, normalizedSegment{ Start: start, End: end, Duration: dur, }) } return out } func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, startSec, durSec float64) error { if strings.TrimSpace(srcPath) == "" { return fmt.Errorf("srcPath leer") } if strings.TrimSpace(outPath) == "" { return fmt.Errorf("outPath leer") } if durSec <= 0 { return fmt.Errorf("dauer <= 0") } tmpPath := outPath + ".part" _ = os.Remove(tmpPath) _ = os.Remove(outPath) ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute) defer cancel() // Re-Encode ist robuster/framesauberer als -c copy args := []string{ "-y", "-hide_banner", "-loglevel", "error", "-ss", formatFFSec(startSec), "-i", srcPath, "-t", formatFFSec(durSec), "-map", "0:v:0", "-map", "0:a?", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", tmpPath, } cmd := exec.CommandContext(ctx, ffmpegPath, args...) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = os.Remove(tmpPath) msg := strings.TrimSpace(stderr.String()) if msg != "" { return fmt.Errorf("%w (%s)", err, msg) } return err } fi, err := os.Stat(tmpPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { _ = os.Remove(tmpPath) return fmt.Errorf("ffmpeg hat keine gültige datei erzeugt") } if err := os.Rename(tmpPath, outPath); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("rename fehlgeschlagen: %w", err) } return nil } func formatFFSec(v float64) string { return strconv.FormatFloat(v, 'f', 3, 64) } func absFloat(v float64) float64 { if v < 0 { return -v } return v }