package video_server import ( "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "FireLeave_tool/logger" ) // MergeVideo merges nearby raw video clips with ffmpeg. func MergeVideo(deviceUUID string, alarmTime int64) (mergedPath string, err error) { return mergeVideo(deviceUUID, deviceUUID, alarmTime) } // MergeVideoFromDirectory reads clips from one device directory while naming // the uploaded file with the real device UUID. func MergeVideoFromDirectory(videoDirectoryUUID, uploadDeviceUUID string, alarmTime int64) (mergedPath string, err error) { return mergeVideo(videoDirectoryUUID, uploadDeviceUUID, alarmTime) } func mergeVideo(deviceUUID, uploadDeviceUUID string, alarmTime int64) (mergedPath string, err error) { startTime := time.Now() logger.Logger.Printf("[%.3f] Start video merge (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) // Guard the merge path so one bad video cannot crash the process. defer func() { if recovered := recover(); recovered != nil { logger.Logger.Printf("[%.3f] Merge video panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), recovered, deviceUUID) mergedPath = "" err = fmt.Errorf("merge video panic: %v", recovered) } }() videoDir := fmt.Sprintf("/usr/data/camera/%s", deviceUUID) logger.Logger.Printf("[%.3f] Check video directory: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) if _, err := os.Stat(videoDir); os.IsNotExist(err) { logger.Logger.Printf("[%.3f] Video directory does not exist: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) return "", fmt.Errorf("video directory not exist: %s", videoDir) } else if err != nil { logger.Logger.Printf("[%.3f] Check video directory failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("check video directory failed: %v", err) } logger.Logger.Printf("[%.3f] Video directory is ready: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) logger.Logger.Printf("[%.3f] Align alarm time to 10-second boundary (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) alarmTimeAligned := alignTo10Seconds(alarmTime) alarmTimeStr := time.Unix(alarmTimeAligned, 0).Format("20060102150405") logger.Logger.Printf("[%.3f] Time aligned: alarmTime=%d aligned=%d (DeviceUUID=%s)", time.Since(startTime).Seconds(), alarmTime, alarmTimeAligned, deviceUUID) beforeTime := alarmTimeAligned - 10 afterTime := alarmTimeAligned + 10 beforeStr := time.Unix(beforeTime, 0).Format("20060102150405") afterStr := time.Unix(afterTime, 0).Format("20060102150405") targetNames := []string{beforeStr, alarmTimeStr, afterStr} videoFiles := []string{} logger.Logger.Printf("[%.3f] Search expected video files (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) missingVideos := []string{} for _, name := range targetNames { if path, ok := findVideoFileForTimestamp(videoDir, name); ok { videoFiles = append(videoFiles, path) logger.Logger.Printf("[%.3f] Found video: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), filepath.Base(path), deviceUUID) } else { missingVideos = append(missingVideos, name+".mp4/.h264") } } if len(missingVideos) > 0 { logger.Logger.Printf("[%.3f] Missing expected videos: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), strings.Join(missingVideos, ","), deviceUUID) } logger.Logger.Printf("[%.3f] Found %d expected video files, searching fallbacks if needed (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 3 { logger.Logger.Printf("[%.3f] Less than 3 expected videos found, scan directory for fallback files (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) allFiles, err := os.ReadDir(videoDir) if err != nil { logger.Logger.Printf("[%.3f] Read video directory failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) } else { logger.Logger.Printf("[%.3f] Directory contains %d files (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(allFiles), deviceUUID) var candidates []struct { path string ts int64 } for _, f := range allFiles { if !f.IsDir() && isSupportedRawVideoFile(f.Name()) { if ts, err := parseSimpleTimestamp(f.Name()); err == nil { candidates = append(candidates, struct { path string ts int64 }{filepath.Join(videoDir, f.Name()), ts}) } } } logger.Logger.Printf("[%.3f] Found %d fallback candidate files (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(candidates), deviceUUID) targets := []int64{beforeTime, alarmTimeAligned, afterTime} for _, target := range targets { if containsTimestamp(videoFiles, target) { continue } closest := findClosest(candidates, target) if closest != "" && !contains(videoFiles, closest) { videoFiles = append(videoFiles, closest) logger.Logger.Printf("[%.3f] Using fallback: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), filepath.Base(closest), deviceUUID) } } } } logger.Logger.Printf("[%.3f] Collected %d video files for merge (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 2 { logger.Logger.Printf("[%.3f] Not enough video files, need at least 2 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) return "", fmt.Errorf("insufficient video files: %d", len(videoFiles)) } logger.Logger.Printf("[%.3f] Sort video files by timestamp (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) sort.Slice(videoFiles, func(i, j int) bool { left, leftErr := getTimestampFromPath(videoFiles[i]) right, rightErr := getTimestampFromPath(videoFiles[j]) if leftErr != nil || rightErr != nil { return videoFiles[i] < videoFiles[j] } return left < right }) logger.Logger.Printf("[%.3f] Merging %d videos (DeviceUUID=%s):", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) for i, f := range videoFiles { logger.Logger.Printf("[%.3f] [%d] %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), i, filepath.Base(f), deviceUUID) } outputFile := fmt.Sprintf("%s.mp4_joined.mp4", alarmTimeStr) joinedPath := filepath.Join(videoDir, outputFile) logger.Logger.Printf("[%.3f] Output temp file: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) logger.Logger.Printf("[%.3f] Remove old output file if present (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) if err := os.Remove(joinedPath); err != nil && !os.IsNotExist(err) { logger.Logger.Printf("[%.3f] Failed to remove old output file %s: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) } else if err == nil { logger.Logger.Printf("[%.3f] Old output file removed: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) } args := []string{"-hide_banner", "-loglevel", "error", "-y"} if allH264Files(videoFiles) { rawJoinedPath := filepath.Join(videoDir, fmt.Sprintf("%s_joined.h264", alarmTimeStr)) logger.Logger.Printf("[%.3f] Concatenate H264 files in timestamp order: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), rawJoinedPath, deviceUUID) if err := concatenateFiles(rawJoinedPath, videoFiles); err != nil { logger.Logger.Printf("[%.3f] Concatenate H264 files failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("concatenate H264 files failed: %v", err) } defer os.Remove(rawJoinedPath) args = append(args, "-fflags", "+genpts", "-f", "h264", "-i", rawJoinedPath, "-c:v", "copy", "-movflags", "+faststart", joinedPath, ) } else { concatListPath := filepath.Join(videoDir, fmt.Sprintf("%s_ffmpeg_concat.txt", alarmTimeStr)) if err := writeFFmpegConcatList(concatListPath, videoFiles); err != nil { logger.Logger.Printf("[%.3f] Create ffmpeg concat list failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("create ffmpeg concat list failed: %v", err) } defer os.Remove(concatListPath) args = append(args, "-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-movflags", "+faststart", joinedPath, ) } cmd := exec.Command("ffmpeg", args...) cmd.Env = ffmpegCleanEnv() cmd.Stdout = logger.Logger.Writer() cmd.Stderr = logger.Logger.Writer() logger.Logger.Printf("[%.3f] Run merge command: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), cmd.Args, deviceUUID) mergeStartTime := time.Now() if err := cmd.Run(); err != nil { logger.Logger.Printf("[%.3f] Merge command failed (elapsed=%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("ffmpeg merge failed: %v", err) } logger.Logger.Printf("[%.3f] Merge command succeeded (elapsed=%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), deviceUUID) logger.Logger.Printf("[%.3f] Verify merged file: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) if _, err := os.Stat(joinedPath); err != nil { logger.Logger.Printf("[%.3f] Output file not created: %s, error: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) return "", fmt.Errorf("output file not created: %s, error: %v", joinedPath, err) } info, err := os.Stat(joinedPath) if err != nil { logger.Logger.Printf("[%.3f] Stat merged file failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("stat output file failed: %v", err) } logger.Logger.Printf("[%.3f] Merged file created: %s (size: %d bytes) (DeviceUUID=%s)", time.Since(startTime).Seconds(), outputFile, info.Size(), deviceUUID) logger.Logger.Printf("[%.3f] Move merged file to /data/upload (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) t := time.Unix(alarmTimeAligned, 0) year, month, day := t.Date() timestampPart := fmt.Sprintf("%d-%d-%d-%d", year, int(month), day, alarmTimeAligned) finalName := fmt.Sprintf("%s-%s.MP4", uploadDeviceUUID, timestampPart) finalPath := filepath.Join("/data/upload", finalName) logger.Logger.Printf("[%.3f] Final upload path: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), finalPath, deviceUUID) logger.Logger.Printf("[%.3f] Ensure /data/upload exists (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) if err := os.MkdirAll("/data/upload", 0755); err != nil { logger.Logger.Printf("[%.3f] Create /data/upload failed: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("create /data/upload failed: %v", err) } logger.Logger.Printf("[%.3f] Copy merged file: %s -> %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, finalPath, deviceUUID) copyStartTime := time.Now() if err := copyFile(joinedPath, finalPath); err != nil { logger.Logger.Printf("[%.3f] Copy merged file failed (elapsed=%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("copy failed: %v", err) } logger.Logger.Printf("[%.3f] Copy merged file succeeded (elapsed=%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), deviceUUID) logger.Logger.Printf("[%.3f] Delete temp merged file: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) if err := os.Remove(joinedPath); err != nil { logger.Logger.Printf("[%.3f] Failed to delete temp file %s: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, err, deviceUUID) } else { logger.Logger.Printf("[%.3f] Temp merged file deleted (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) } logger.Logger.Printf("[%.3f] Video merged and moved successfully: %s (DeviceUUID=%s, total=%.3fs)", time.Since(startTime).Seconds(), finalPath, deviceUUID, time.Since(startTime).Seconds()) return finalPath, nil } // containsTimestamp reports whether files include the given timestamp.mp(files []string, ts int64) bool { func containsTimestamp(files []string, ts int64) bool { for _, f := range files { timestamp, err := getTimestampFromPath(f) if err == nil && timestamp == ts { return true } } return false } func findVideoFileForTimestamp(videoDir, timestamp string) (string, bool) { for _, ext := range []string{".mp4", ".h264"} { path := filepath.Join(videoDir, timestamp+ext) if _, err := os.Stat(path); err == nil { return path, true } } return "", false } func isSupportedRawVideoFile(name string) bool { if strings.Contains(name, "_joined") { return false } ext := strings.ToLower(filepath.Ext(name)) return ext == ".mp4" || ext == ".h264" } func writeFFmpegConcatList(path string, videoFiles []string) error { var builder strings.Builder for _, videoFile := range videoFiles { escaped := strings.ReplaceAll(videoFile, "'", "'\\''") builder.WriteString("file '") builder.WriteString(escaped) builder.WriteString("'\n") } return os.WriteFile(path, []byte(builder.String()), 0644) } func allH264Files(videoFiles []string) bool { for _, videoFile := range videoFiles { if !strings.EqualFold(filepath.Ext(videoFile), ".h264") { return false } } return len(videoFiles) > 0 } func concatenateFiles(outputPath string, inputPaths []string) (err error) { output, err := os.Create(outputPath) if err != nil { return err } defer func() { if closeErr := output.Close(); err == nil { err = closeErr } if err != nil { _ = os.Remove(outputPath) } }() for _, inputPath := range inputPaths { input, openErr := os.Open(inputPath) if openErr != nil { return fmt.Errorf("open %s: %w", inputPath, openErr) } _, copyErr := io.Copy(output, input) closeErr := input.Close() if copyErr != nil { return fmt.Errorf("append %s: %w", inputPath, copyErr) } if closeErr != nil { return fmt.Errorf("close %s: %w", inputPath, closeErr) } } return output.Sync() } func ffmpegCleanEnv() []string { env := os.Environ() cleaned := make([]string, 0, len(env)) for _, item := range env { if strings.HasPrefix(item, "LD_LIBRARY_PATH=") { continue } cleaned = append(cleaned, item) } return cleaned } func findClosest(candidates []struct { path string ts int64 }, target int64) string { var best string var minDiff int64 = 1<<63 - 1 for _, c := range candidates { diff := abs(c.ts - target) if diff < minDiff { minDiff = diff best = c.path } } if minDiff <= 30 { return best } return "" } func abs(x int64) int64 { if x < 0 { return -x } return x } func copyFile(src, dst string) error { startTime := time.Now() logger.Logger.Printf("[%.3f] Start file copy: %s -> %s", time.Since(startTime).Seconds(), src, dst) in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() deadline := time.Now().Add(30 * time.Second) buf := make([]byte, 1024*1024) var written int64 for { if time.Now().After(deadline) { _ = out.Close() _ = os.Remove(dst) logger.Logger.Printf("[%.3f] Copy timed out, removed incomplete file: %s", time.Since(startTime).Seconds(), dst) return fmt.Errorf("copy file timeout after %d bytes", written) } nr, readErr := in.Read(buf) if nr > 0 { nw, writeErr := out.Write(buf[:nr]) written += int64(nw) if writeErr != nil { _ = os.Remove(dst) return writeErr } if nw != nr { _ = os.Remove(dst) return io.ErrShortWrite } } if readErr == io.EOF { break } if readErr != nil { _ = os.Remove(dst) return readErr } } if err := out.Sync(); err != nil { _ = os.Remove(dst) return err } logger.Logger.Printf("[%.3f] file copy completed: %d bytes", time.Since(startTime).Seconds(), written) return nil } /* func copyFile(src, dst string) error { startTime := time.Now() logger.Logger.Printf("[%.3f] Start file copy: %s -> %s", time.Since(startTime).Seconds(), src, dst) in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() deadline := time.Now().Add(30 * time.Second) buf := make([]byte, 1024*1024) var written int64 for { if time.Now().After(deadline) { _ = out.Close() _ = os.Remove(dst) logger.Logger.Printf("[%.3f] Copy timed out, removed incomplete file: %s", time.Since(startTime).Seconds(), dst) return fmt.Errorf("copy file timeout after %d bytes", written) } nr, readErr := in.Read(buf) if nr > 0 { nw, writeErr := out.Write(buf[:nr]) written += int64(nw) if writeErr != nil { _ = os.Remove(dst) return writeErr } if nw != nr { _ = os.Remove(dst) return io.ErrShortWrite } } if readErr == io.EOF { break } if readErr != nil { _ = os.Remove(dst) return readErr } } if err := out.Sync(); err != nil { _ = os.Remove(dst) return err } logger.Logger.Printf("[%.3f] file copy completed: %d bytes", time.Since(startTime).Seconds(), written) return nil } // alignTo10Seconds 鐏忓棙妞傞梻鏉戭嚠姒绘劕鍩岄張鈧潻鎴犳畱10缁夋帡妫块梾?*/ func alignTo10Seconds(timestamp int64) int64 { t := time.Unix(timestamp, 0) seconds := t.Second() alignedSeconds := (seconds / 10) * 10 alignedTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), alignedSeconds, 0, t.Location()) return alignedTime.Unix() } func parseSimpleTimestamp(filename string) (int64, error) { nameWithoutExt := strings.TrimSuffix(filename, filepath.Ext(filename)) if len(nameWithoutExt) != 14 { return 0, fmt.Errorf("invalid timestamp filename length: %s", filename) } year, err := strconv.Atoi(nameWithoutExt[0:4]) // 鐟欙絾鐎介弮鍫曟?閹? year, err := strconv.Atoi(nameWithoutExt[0:4]) if err != nil { return 0, fmt.Errorf("invalid year in filename %s: %w", filename, err) } month, err := strconv.Atoi(nameWithoutExt[4:6]) if err != nil { return 0, fmt.Errorf("invalid month in filename %s: %w", filename, err) } day, err := strconv.Atoi(nameWithoutExt[6:8]) if err != nil { return 0, fmt.Errorf("invalid day in filename %s: %w", filename, err) } hour, err := strconv.Atoi(nameWithoutExt[8:10]) if err != nil { return 0, fmt.Errorf("invalid hour in filename %s: %w", filename, err) } minute, err := strconv.Atoi(nameWithoutExt[10:12]) if err != nil { return 0, fmt.Errorf("invalid minute in filename %s: %w", filename, err) } second, err := strconv.Atoi(nameWithoutExt[12:14]) if err != nil { return 0, fmt.Errorf("invalid second in filename %s: %w", filename, err) } t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.Local) return t.Unix(), nil } // getTimestampFromPath func getTimestampFromPath(filePath string) (int64, error) { filename := filepath.Base(filePath) timestamp, err := parseSimpleTimestamp(filename) if err != nil { return 0, err } return timestamp, nil } // contains func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } // CleanupOldVideos func CleanupOldVideos(deviceUUID string) { const maxFiles = 10 videoDir := "/data/upload" prefix := fmt.Sprintf("%s_", deviceUUID) files, err := os.ReadDir(videoDir) if err != nil { logger.Logger.Printf("read the signals %s Error: %v", videoDir, err) return } var videoFiles []os.DirEntry for _, file := range files { if strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), "_merged.mp4") { videoFiles = append(videoFiles, file) } } if len(videoFiles) <= maxFiles { return } sort.Slice(videoFiles, func(i, j int) bool { infoI, _ := videoFiles[i].Info() infoJ, _ := videoFiles[j].Info() return infoI.ModTime().Before(infoJ.ModTime()) }) for i := 0; i < len(videoFiles)-maxFiles; i++ { filePath := filepath.Join(videoDir, videoFiles[i].Name()) if err := os.Remove(filePath); err != nil { logger.Logger.Printf("Clean up old videos %s Error: %v", filePath, err) } else { logger.Logger.Printf("Clean up old videos: %s", filePath) } } } // CleanupOldRawVideos func CleanupOldRawVideos(deviceUUID string) { // 濞撳懐鎮婃稉鏄忣啎婢跺洨娲拌ぐ? cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID)) cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID)) // 濞撳懐鎮婇悘顐ゅ姍濡偓濞村娲拌ぐ? cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check")) cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check")) } func cleanupRawVideosDirectory(rawDir string) { if _, err := os.Stat(rawDir); os.IsNotExist(err) { return } files, err := os.ReadDir(rawDir) if err != nil { logger.Logger.Printf("Failed to read the original video directory %s: %v", rawDir, err) return } var videoFiles []string for _, file := range files { if !file.IsDir() && filepath.Ext(file.Name()) == ".mp4" { videoFiles = append(videoFiles, filepath.Join(rawDir, file.Name())) } } if len(videoFiles) <= 60 { return // 娑撳秷绉存潻?0閺夆槄绱濇稉宥嗙閻? } } sort.Slice(videoFiles, func(i, j int) bool { fi, _ := os.Stat(videoFiles[i]) fj, _ := os.Stat(videoFiles[j]) return fi.ModTime().Before(fj.ModTime()) }) for i := 0; i < len(videoFiles)-60; i++ { if err := os.Remove(videoFiles[i]); err != nil { logger.Logger.Printf("Failed to delete the old original video %s: %v", videoFiles[i], err) } else { logger.Logger.Printf("Clear the old original videos: %s", videoFiles[i]) } } }