package video_server import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "FireLeave_tool/logger" ) // MergeVideo 合并视频文件 - 完全适配 mp4_merge --out 模式 func MergeVideo(deviceUUID string, alarmTime int64) (string, error) { startTime := time.Now() logger.Logger.Printf("[%.3f] 开始合并视频 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) // 捕获所有异常,确保函数不会因为panic而崩溃 defer func() { if err := recover(); err != nil { logger.Logger.Printf("[%.3f] 合并视频发生panic: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) } }() // 检查视频目录 videoDir := fmt.Sprintf("/usr/data/camera/%s", deviceUUID) logger.Logger.Printf("[%.3f] 检查视频目录: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) if _, err := os.Stat(videoDir); os.IsNotExist(err) { logger.Logger.Printf("[%.3f] 视频目录不存在: %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] 检查视频目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("check video directory failed: %v", err) } logger.Logger.Printf("[%.3f] 视频目录检查成功: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), videoDir, deviceUUID) // === 1. 时间对齐到 10 秒 === logger.Logger.Printf("[%.3f] 开始时间对齐处理 (DeviceUUID=%s, alarmTime=%d)", time.Since(startTime).Seconds(), deviceUUID, alarmTime) alarmTimeAligned := alignTo10Seconds(alarmTime) alarmTimeStr := time.Unix(alarmTimeAligned, 0).Format("20060102150405") logger.Logger.Printf("[%.3f] 时间对齐结果: 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") paths := map[string]string{ beforeStr: filepath.Join(videoDir, beforeStr+".mp4"), alarmTimeStr: filepath.Join(videoDir, alarmTimeStr+".mp4"), afterStr: filepath.Join(videoDir, afterStr+".mp4"), } // 查找视频文件 videoFiles := []string{} logger.Logger.Printf("[%.3f] 开始查找视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) for name, path := range paths { logger.Logger.Printf("[%.3f] 检查文件: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), path, deviceUUID) if _, err := os.Stat(path); err == nil { videoFiles = append(videoFiles, path) logger.Logger.Printf("[%.3f] Found video: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), name+".mp4", deviceUUID) } else { logger.Logger.Printf("[%.3f] Missing video: %s (DeviceUUID=%s, error: %v)", time.Since(startTime).Seconds(), name+".mp4", deviceUUID, err) } } // === 2. 补充替代文件 === logger.Logger.Printf("[%.3f] 当前找到 %d 个视频文件,开始补充替代文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 3 { logger.Logger.Printf("[%.3f] 视频文件不足3个,开始查找替代文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) allFiles, err := os.ReadDir(videoDir) if err != nil { logger.Logger.Printf("[%.3f] 读取目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) } else { logger.Logger.Printf("[%.3f] 读取目录所有文件,共 %d 个文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(allFiles), deviceUUID) var candidates []struct { path string ts int64 } for _, f := range allFiles { if !f.IsDir() && strings.HasSuffix(f.Name(), ".mp4") && !strings.Contains(f.Name(), "_joined") { 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] 找到 %d 个候选视频文件 (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] 最终收集到 %d 个视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), len(videoFiles), deviceUUID) if len(videoFiles) < 2 { logger.Logger.Printf("[%.3f] 视频文件不足,至少需要 2 个文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) return "", fmt.Errorf("insufficient video files: %d", len(videoFiles)) } // === 3. 排序 === logger.Logger.Printf("[%.3f] 开始排序视频文件 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) sort.Slice(videoFiles, func(i, j int) bool { return getTimestampFromPath(videoFiles[i]) < getTimestampFromPath(videoFiles[j]) }) 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) } // === 4. 显式指定输出文件(100% 可控)=== outputFile := fmt.Sprintf("%s.mp4_joined.mp4", alarmTimeStr) joinedPath := filepath.Join(videoDir, outputFile) logger.Logger.Printf("[%.3f] 输出文件路径: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) // 清理旧文件(防止冲突) logger.Logger.Printf("[%.3f] 清理旧的输出文件 (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] 旧的输出文件清理成功: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) } // === 5. 调用 mp4_merge,显式 --out === args := append(videoFiles, "--out", joinedPath) cmd := exec.Command("mp4_merge", args...) cmd.Stdout = logger.Logger.Writer() cmd.Stderr = logger.Logger.Writer() logger.Logger.Printf("[%.3f] 开始执行视频合并命令: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), cmd.Args, deviceUUID) mergeStartTime := time.Now() if err := cmd.Run(); err != nil { logger.Logger.Printf("[%.3f] 视频合并命令执行失败 (耗时:%.3fs): %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("mp4_merge failed: %v", err) } logger.Logger.Printf("[%.3f] 视频合并命令执行成功 (耗时:%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(mergeStartTime).Seconds(), deviceUUID) // === 6. 验证输出文件存在 === logger.Logger.Printf("[%.3f] 验证输出文件是否存在: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), joinedPath, deviceUUID) if _, err := os.Stat(joinedPath); err != nil { logger.Logger.Printf("[%.3f] 输出文件未创建: %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] 获取输出文件信息失败: %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) // === 7. 移动到 /data/upload/ === logger.Logger.Printf("[%.3f] 开始移动文件到 /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", deviceUUID, timestampPart) finalPath := filepath.Join("/data/upload", finalName) logger.Logger.Printf("[%.3f] 最终文件路径: %s (DeviceUUID=%s)", time.Since(startTime).Seconds(), finalPath, deviceUUID) logger.Logger.Printf("[%.3f] 确保 /data/upload 目录存在 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) if err := os.MkdirAll("/data/upload", 0755); err != nil { logger.Logger.Printf("[%.3f] 创建 /data/upload 目录失败: %v (DeviceUUID=%s)", time.Since(startTime).Seconds(), err, deviceUUID) return "", fmt.Errorf("create /data/upload failed: %v", err) } // 跨分区复制 + 删除 logger.Logger.Printf("[%.3f] 开始复制文件: %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] 文件复制失败 (耗时:%.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] 文件复制成功 (耗时:%.3fs) (DeviceUUID=%s)", time.Since(startTime).Seconds(), time.Since(copyStartTime).Seconds(), deviceUUID) logger.Logger.Printf("[%.3f] 删除临时文件: %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] 临时文件删除成功 (DeviceUUID=%s)", time.Since(startTime).Seconds(), deviceUUID) } logger.Logger.Printf("[%.3f] Video merged and moved successfully: %s (DeviceUUID=%s, 总耗时:%.3fs)", time.Since(startTime).Seconds(), finalPath, deviceUUID, time.Since(startTime).Seconds()) return finalPath, nil } // containsTimestamp 检查是否已有该时间戳 func containsTimestamp(files []string, ts int64) bool { for _, f := range files { if getTimestampFromPath(f) == ts { return true } } return false } // findClosest 查找最接近的时间戳文件 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 } // copyFile 支持跨分区复制,带超时机制 func copyFile(src, dst string) error { startTime := time.Now() logger.Logger.Printf("[%.3f] 开始复制文件: %s → %s", time.Since(startTime).Seconds(), src, dst) // 创建带超时的上下文 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 创建通道用于接收复制结果 type copyResult struct { n int64 err error } resultChan := make(chan copyResult, 1) // 在goroutine中执行复制操作 go func() { // 打开源文件 logger.Logger.Printf("[%.3f] 打开源文件: %s", time.Since(startTime).Seconds(), src) in, err := os.Open(src) if err != nil { resultChan <- copyResult{0, err} return } defer in.Close() // 创建目标文件 logger.Logger.Printf("[%.3f] 创建目标文件: %s", time.Since(startTime).Seconds(), dst) out, err := os.Create(dst) if err != nil { resultChan <- copyResult{0, err} return } defer out.Close() // 获取源文件大小 fileInfo, err := in.Stat() if err != nil { logger.Logger.Printf("[%.3f] 获取源文件信息失败: %v", time.Since(startTime).Seconds(), err) } else { logger.Logger.Printf("[%.3f] 源文件大小: %d 字节", time.Since(startTime).Seconds(), fileInfo.Size()) } // 执行复制 logger.Logger.Printf("[%.3f] 开始执行文件复制操作", time.Since(startTime).Seconds()) copyStartTime := time.Now() n, err := io.Copy(out, in) elapsed := time.Since(copyStartTime) if err != nil { logger.Logger.Printf("[%.3f] 文件复制失败: %v, 已复制 %d 字节, 耗时 %v", time.Since(startTime).Seconds(), err, n, elapsed) resultChan <- copyResult{n, err} return } logger.Logger.Printf("[%.3f] 文件复制成功: 复制 %d 字节, 耗时 %v", time.Since(startTime).Seconds(), n, elapsed) // 同步文件到磁盘 logger.Logger.Printf("[%.3f] 开始同步文件到磁盘", time.Since(startTime).Seconds()) syncStartTime := time.Now() if err = out.Sync(); err != nil { logger.Logger.Printf("[%.3f] 文件同步失败: %v, 耗时 %v", time.Since(startTime).Seconds(), err, time.Since(syncStartTime)) resultChan <- copyResult{n, err} return } logger.Logger.Printf("[%.3f] 文件同步成功, 耗时 %v", time.Since(startTime).Seconds(), time.Since(syncStartTime)) resultChan <- copyResult{n, nil} }() // 等待复制结果或超时 select { case result := <-resultChan: if result.err != nil { return result.err } case <-ctx.Done(): err := ctx.Err() logger.Logger.Printf("[%.3f] 文件复制操作超时: %v", time.Since(startTime).Seconds(), err) return fmt.Errorf("copy file timeout: %v", err) } logger.Logger.Printf("[%.3f] 文件复制操作完成", time.Since(startTime).Seconds()) return nil } // alignTo10Seconds 将时间对齐到最近的10秒间隔 func alignTo10Seconds(timestamp int64) int64 { t := time.Unix(timestamp, 0) // 获取秒数并对齐到10秒 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() } // parseSimpleTimestamp 解析简单的时间戳文件名 func parseSimpleTimestamp(filename string) (int64, error) { nameWithoutExt := strings.TrimSuffix(filename, filepath.Ext(filename)) if len(nameWithoutExt) != 14 { } // 解析时间戳 year, _ := strconv.Atoi(nameWithoutExt[0:4]) month, _ := strconv.Atoi(nameWithoutExt[4:6]) day, _ := strconv.Atoi(nameWithoutExt[6:8]) hour, _ := strconv.Atoi(nameWithoutExt[8:10]) minute, _ := strconv.Atoi(nameWithoutExt[10:12]) second, _ := strconv.Atoi(nameWithoutExt[12:14]) t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.Local) return t.Unix(), nil } // getTimestampFromPath 从文件路径中提取时间戳 func getTimestampFromPath(filePath string) int64 { filename := filepath.Base(filePath) timestamp, err := parseSimpleTimestamp(filename) if err != nil { return 0 } return timestamp } // contains 检查切片是否包含某个元素 func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } // CleanupOldVideos 清理旧的合并视频文件,保留最近的 10 个 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 清理推理程序生成的原始10s视频,保留最近60条 func CleanupOldRawVideos(deviceUUID string) { // 清理主设备目录 cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID)) // 清理火焰检测目录 cleanupRawVideosDirectory(filepath.Join("/usr/data/camera", deviceUUID+"_fire_check")) } // cleanupRawVideosDirectory 清理单个目录中的原始视频文件 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 // 不超过60条,不清理 } 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]) } } }