719 lines
16 KiB
Go
719 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"
|
|
"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
|
|
}
|