FireLeave_tool/video_server/video_merge.go
2026-05-12 15:45:50 +08:00

463 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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])
}
}
}