298 lines
6.7 KiB
Go
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
|
|
}
|