nsfwapp/backend/split.go
2026-03-14 14:28:33 +01:00

298 lines
6.7 KiB
Go

// 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
}