nsfwapp/backend/split.go
2026-05-11 19:13:54 +02:00

723 lines
16 KiB
Go

// backend\split.go
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"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...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
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
}