// backend\split.go package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" ) type splitVideoSegmentIn struct { Start float64 `json:"start"` End float64 `json:"end"` Label string `json:"label,omitempty"` } 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] Segments []splitVideoSegmentIn `json:"segments"` // echte Segmente DeleteOriginal bool `json:"deleteOriginal,omitempty"` } type splitVideoSegmentResponse struct { Index int `json:"index"` Label string `json:"label,omitempty"` 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"` } type splitProgressEvent struct { Type string `json:"type"` OK bool `json:"ok,omitempty"` File string `json:"file,omitempty"` Current int `json:"current,omitempty"` Total int `json:"total,omitempty"` Progress float64 `json:"progress,omitempty"` // 0..1 aktuelles Segment Overall float64 `json:"overall,omitempty"` // 0..1 gesamt Start float64 `json:"start,omitempty"` End float64 `json:"end,omitempty"` Message string `json:"message,omitempty"` Segment *splitVideoSegmentResponse `json:"segment,omitempty"` Segments []splitVideoSegmentResponse `json:"segments,omitempty"` } 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 } stream := r.URL.Query().Get("stream") == "1" 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) probeCtx, probeCancel := context.WithTimeout(r.Context(), 20*time.Second) durationSec, err := durationSecondsCached(probeCtx, srcPath) probeCancel() if err != nil || durationSec <= 0 { http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError) return } var segments []normalizedSegment if len(req.Segments) > 0 { segments, err = normalizeSegments(req.Segments, durationSec) if err != nil { http.Error(w, "segments ungültig: "+err.Error(), http.StatusBadRequest) return } } else { 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 } } splitCtx, splitCancel := context.WithTimeout(r.Context(), splitTimeoutForSegments(len(segments))) defer splitCancel() 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, } var flusher http.Flusher if stream { var ok bool flusher, ok = w.(http.Flusher) if !ok { http.Error(w, "streaming wird von diesem server nicht unterstützt", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "start", OK: true, File: req.File, Total: len(segments), Message: "Split gestartet", }) } for i, seg := range segments { rawName := fmt.Sprintf("%s_%d%s", base, i+1, ext) outPath := uniqueSplitOutPath(outDir, rawName) outName := filepath.Base(outPath) if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "segment_start", File: req.File, Current: i + 1, Total: len(segments), Start: seg.Start, End: seg.End, Overall: float64(i) / float64(len(segments)), Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)), }) } err := splitSingleSegment(splitCtx, srcPath, outPath, seg.Start, seg.Duration, func(ratio float64) { if !stream { return } _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "segment_progress", File: req.File, Current: i + 1, Total: len(segments), Start: seg.Start, End: seg.End, Progress: clamp01(ratio), Overall: clamp01((float64(i) + clamp01(ratio)) / float64(len(segments))), Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)), }) }) if err != nil { if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "error", File: req.File, Current: i + 1, Total: len(segments), Start: seg.Start, End: seg.End, Message: fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), }) return } http.Error( w, fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), http.StatusInternalServerError, ) return } segResp := splitVideoSegmentResponse{ Index: i + 1, Label: seg.Label, Start: seg.Start, End: seg.End, Duration: seg.Duration, File: outName, Path: outPath, } resp.Segments = append(resp.Segments, segResp) if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "segment_done", File: req.File, Current: i + 1, Total: len(segments), Start: seg.Start, End: seg.End, Overall: float64(i+1) / float64(len(segments)), Segment: &segResp, Message: fmt.Sprintf("Segment %d/%d fertig", i+1, len(segments)), }) } } if req.DeleteOriginal { originalFile := filepath.Base(srcPath) originalID := stripHotPrefix(strings.TrimSuffix(originalFile, filepath.Ext(originalFile))) releaseFileForMutation(originalFile) if err := removeWithRetry(srcPath); err != nil { if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "error", File: req.File, Total: len(resp.Segments), Overall: 1, Message: "Originaldatei konnte nicht gelöscht werden: " + err.Error(), }) return } http.Error(w, "Originaldatei konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) return } purgeDurationCacheForPath(srcPath) removeJobsByOutputBasename(originalFile) if strings.TrimSpace(originalID) != "" { removeGeneratedForID(originalID) } if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "source_deleted", File: req.File, Total: len(resp.Segments), Overall: 1, Message: "Originaldatei gelöscht", }) } } notifyDoneChanged() if stream { _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ Type: "done", OK: true, File: req.File, Total: len(resp.Segments), Overall: 1, Segments: resp.Segments, Message: "Split abgeschlossen", }) return } respondJSON(w, resp) } func sanitizeTimeForFilename(v float64) string { if v < 0 { v = 0 } return strings.ReplaceAll(formatFFSec(v), ".", "_") } func splitTimeoutForSegments(count int) time.Duration { if count < 1 { count = 1 } // grob 2 Minuten pro Segment + Puffer d := time.Duration(count)*2*time.Minute + 30*time.Second if d < 5*time.Minute { d = 5 * time.Minute } if d > 2*time.Hour { d = 2 * time.Hour } return d } func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error { b, err := json.Marshal(ev) if err != nil { return err } if _, err := w.Write(append(b, '\n')); err != nil { return err } flusher.Flush() return nil } type normalizedSegment struct { Label string Start float64 End float64 Duration float64 } func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) { if duration <= 0 { return nil, appErrorf("duration <= 0") } if len(raw) == 0 { return nil, appErrorf("keine segmente übergeben") } out := make([]normalizedSegment, 0, len(raw)) for _, seg := range raw { start := seg.Start end := seg.End if start > end { start, end = end, start } if start < 0 { start = 0 } if end > duration { end = duration } dur := end - start if dur <= 0.10 { continue } out = append(out, normalizedSegment{ Label: strings.TrimSpace(seg.Label), Start: start, End: end, Duration: dur, }) } if len(out) == 0 { return nil, appErrorf("keine gültigen segmente übrig") } sort.Slice(out, func(i, j int) bool { if out[i].Start == out[j].Start { return out[i].End < out[j].End } return out[i].Start < out[j].Start }) // near-duplicate entfernen dedup := make([]normalizedSegment, 0, len(out)) for _, seg := range out { if len(dedup) == 0 { dedup = append(dedup, seg) continue } last := dedup[len(dedup)-1] sameStart := absFloat(last.Start-seg.Start) < 0.20 sameEnd := absFloat(last.End-seg.End) < 0.20 if sameStart && sameEnd { continue } dedup = append(dedup, seg) } if len(dedup) == 0 { return nil, appErrorf("keine eindeutigen segmente übrig") } return dedup, nil } func uniqueSplitOutPath(dir, fileName string) string { ext := filepath.Ext(fileName) base := strings.TrimSuffix(fileName, ext) outPath := filepath.Join(dir, fileName) if _, err := os.Stat(outPath); err == nil { // existiert schon -> unten nächsten Namen suchen } else if os.IsNotExist(err) { return outPath } for i := 2; i < 10000; i++ { candidate := filepath.Join(dir, fmt.Sprintf("%s__%02d%s", base, i, ext)) if _, err := os.Stat(candidate); os.IsNotExist(err) { return candidate } } return filepath.Join(dir, fmt.Sprintf("%s__%d%s", base, time.Now().UnixNano(), ext)) } func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { if duration <= 0 { return nil, appErrorf("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, appErrorf("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, appErrorf("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 splitSingleSegmentTimeout(durSec float64) time.Duration { if durSec <= 0 { return 10 * time.Minute } d := time.Duration(durSec*4)*time.Second + 2*time.Minute if d < 5*time.Minute { d = 5 * time.Minute } if d > 90*time.Minute { d = 90 * time.Minute } return d } func splitSingleSegment( parentCtx context.Context, srcPath, outPath string, startSec, durSec float64, onProgress func(ratio float64), ) error { if strings.TrimSpace(srcPath) == "" { return appErrorf("srcPath leer") } if strings.TrimSpace(outPath) == "" { return appErrorf("outPath leer") } if durSec <= 0 { return appErrorf("dauer <= 0") } ext := strings.ToLower(filepath.Ext(outPath)) base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) tmpPath := base + ".part" + ext _ = os.Remove(tmpPath) timeout := splitSingleSegmentTimeout(durSec) ctx, cancel := context.WithTimeout(parentCtx, timeout) defer cancel() formatName := "mp4" if ext == ".ts" { formatName = "mpegts" } args := []string{ "-y", "-nostats", "-progress", "pipe:1", "-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", "-f", formatName, tmpPath, } cmd := exec.CommandContext(ctx, ffmpegPath, args...) hideCommandWindow(cmd) stdout, err := cmd.StdoutPipe() if err != nil { return err } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return err } if onProgress != nil { onProgress(0) } sc := bufio.NewScanner(stdout) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) lastRatio := -1.0 send := func(outSec float64, force bool) { if onProgress == nil || durSec <= 0 { return } ratio := clamp01(outSec / durSec) if !force && lastRatio >= 0 && absFloat(lastRatio-ratio) < 0.01 { return } lastRatio = ratio onProgress(ratio) } for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } switch k { case "out_time_us": if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { send(float64(n)/1_000_000.0, false) } case "out_time_ms": if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { send(float64(n)/1_000.0, false) } case "out_time": if s := parseFFmpegOutTime(v); s > 0 { send(s, false) } case "progress": if strings.TrimSpace(v) == "end" { send(durSec, true) } } } if err := sc.Err(); err != nil { _ = os.Remove(tmpPath) return err } if err := cmd.Wait(); err != nil { _ = os.Remove(tmpPath) if ctx.Err() != nil { return appErrorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err()) } msg := strings.TrimSpace(stderr.String()) if msg != "" { return appErrorf("%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 appErrorf("ffmpeg hat keine gültige datei erzeugt") } if err := os.Rename(tmpPath, outPath); err != nil { _ = os.Remove(tmpPath) return appErrorf("rename fehlgeschlagen: %w", err) } if onProgress != nil { onProgress(1) } 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 }