/* * @Author: BlackTeay * @Date: 2024-04-30 09:37:39 * @LastEditTime: 2024-05-06 16:37:14 * @LastEditors: BlackTeay * @Description: * @FilePath: /hls_builder/main.go * Copyright 2024 JLNTV NMTD, All Rights Reserved. */ package main import ( "bufio" "embed" _ "embed" "errors" "fmt" "image/color" "log" "math" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/google/uuid" ) //go:embed "font/NotoSansSC-Regular.ttf" var ttfBytes []byte //go:embed bin/* var ffmpegFS embed.FS type MyTheme struct{} func (MyTheme) Color(c fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { return theme.DefaultTheme().Color(c, v) } func (MyTheme) Font(s fyne.TextStyle) fyne.Resource { return fyne.NewStaticResource("./font/NotoSansSC-Regular.ttf", ttfBytes) } func (MyTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(n) } func (MyTheme) Size(s fyne.ThemeSizeName) float32 { return theme.DefaultTheme().Size(s) } func getFFmpegPath() string { var ffmpegFileName string switch runtime.GOOS { case "windows": ffmpegFileName = "ffmpeg_windows.exe" case "darwin": ffmpegFileName = "ffmpeg_darwin" } // 读取文件内容 data, err := ffmpegFS.ReadFile("bin/" + ffmpegFileName) if err != nil { log.Fatal(err) } // 写入到临时文件中 tmpDir, err := os.MkdirTemp("", "ffmpeg") if err != nil { log.Fatal(err) } tmpFilePath := filepath.Join(tmpDir, ffmpegFileName) if err := os.WriteFile(tmpFilePath, data, 0755); err != nil { log.Fatal(err) } return tmpFilePath } func getUs3cliPath() string { var us3cliFileName string switch runtime.GOOS { case "windows": us3cliFileName = "us3cli-windows.exe" case "darwin": us3cliFileName = "us3cli-mac" } // 读取文件内容 data, err := ffmpegFS.ReadFile("bin/" + us3cliFileName) if err != nil { log.Fatal(err) } // 写入到临时文件中 tmpDir, err := os.MkdirTemp("", "us3cli") if err != nil { log.Fatal(err) } tmpFilePath := filepath.Join(tmpDir, us3cliFileName) if err := os.WriteFile(tmpFilePath, data, 0755); err != nil { log.Fatal(err) } return tmpFilePath } func main() { //打印程序当前路径 log.Println("Current path:", os.Getenv("PWD")) myApp := app.NewWithID("hls_builder") myApp.Settings().SetTheme(MyTheme{}) myWindow := myApp.NewWindow("HLS-builder 视频切片工具") // 设置窗口大小 myWindow.Resize(fyne.NewSize(800, 600)) // 设置窗口宽为800像素,高为600像素 inputFile := widget.NewLabel("请选择mp4文件!") outputDir := widget.NewLabel("请选择输出路径!") progressBar := widget.NewProgressBar() btnSelectFile := widget.NewButton("选择mp4文件", func() { filter := storage.NewExtensionFileFilter([]string{".mp4"}) fileDialog := dialog.NewFileOpen(func(file fyne.URIReadCloser, err error) { if err != nil { log.Println("Error opening file:", err) return } if file != nil { log.Println("Opened file:", file.URI().Path()) inputFile.SetText(file.URI().Path()) } }, myWindow) fileDialog.SetFilter(filter) // 设置文件过滤器 fileDialog.Show() }) btnSelectOutput := widget.NewButton("选择输出路径", func() { dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { if err == nil && uri != nil { log.Println("Selected folder:", uri.Path()) outputDir.SetText(uri.Path()) } }, myWindow) }) var btnSlice *widget.Button // 预先声明btnSlice变量 var randDir string var hlsDir string btnSlice = widget.NewButton("开始切片", func() { if inputFile.Text == "没有选择视频文件" || outputDir.Text == "没有选择输出路径" { dialog.ShowError(errors.New("请选择视频文件和输出路径"), myWindow) return } // 禁用按钮 btnSelectFile.Disable() btnSelectOutput.Disable() btnSlice.Disable() btnSlice.SetText("切片中...") randDir = uuid.New().String() //在outputDir中创建一个文件夹,名为一个UUID字符串 hlsDir = outputDir.Text + "/" + randDir //新建这个目录 err := os.Mkdir(hlsDir, 0755) if err != nil { dialog.ShowError(errors.New("建立随机目录出错!"), myWindow) } go sliceVideo(inputFile.Text, hlsDir, progressBar, func() { // UI updates directly in the callback btnSelectFile.Enable() btnSelectOutput.Enable() btnSlice.Enable() btnSlice.SetText("开始切片") // You may also want to display a dialog or notification // dialog.ShowInformation("完成", "Video slicing completed successfully.", myWindow) }, btnSelectFile, btnSelectOutput, btnSlice, myWindow) }) uploadLabel := widget.NewLabel("上传切片到Ucloud") var btnUpload *widget.Button btnUpload = widget.NewButton("上传到服务器", func() { go upload(hlsDir, randDir, func() { btnUpload.Enable() }) }) // btnUpload.Disable() content := container.NewVBox( inputFile, btnSelectFile, outputDir, btnSelectOutput, progressBar, btnSlice, uploadLabel, btnUpload, ) myWindow.SetContent(content) myWindow.ShowAndRun() } func sliceVideo(inputFile, outputDir string, progressBar *widget.ProgressBar, onComplete func(), btnSelectFile, btnSelectOutput, btnSlice *widget.Button, myWindow fyne.Window) { ffmpegPath := getFFmpegPath() startTime := time.Now() // 记录开始时间 log.Println("开始切割视频") // 调整 FFmpeg 命令以输出调试信息,有助于解析 cmd := exec.Command( ffmpegPath, "-i", inputFile, // 输入文件 "-c", "copy", // 复制编解码器设置 "-start_number", "0", // HLS 段的起始编号 "-hls_time", "5", // 每个 HLS 段的最大时长(秒) "-hls_list_size", "0", // 播放列表中的最大段数,0表示无限制 "-f", "hls", // 输出格式为 HLS "-hls_segment_filename", outputDir+"/%08d.ts", // 段文件的命名方式 outputDir+"/playlist.m3u8", // 输出的 m3u8 文件位置 ) // cmd.Stderr = os.Stderr // 将 stderr 直接定向到系统的 stderr,以便直接调试 FFmpeg 的输出 cmdReader, err := cmd.StderrPipe() // 现在使用 stderr 管道而不是 stdout if err != nil { log.Fatal("创建 stderr 管道失败:", err) } if err := cmd.Start(); err != nil { log.Fatalf("cmd.Start() 失败: %s\n", err) } scanner := bufio.NewScanner(cmdReader) totalDuration := getTotalDuration(inputFile) // 确保此函数正确获取总持续时间 log.Printf("视频总时长%f\n", totalDuration) segment := math.Ceil(totalDuration / 5) log.Println(segment) // currentDuration := 0.0 go func() { var completed bool for scanner.Scan() { line := scanner.Text() log.Println("FFmpeg 输出:", line) if segmentCount := parseFFmpegProgressOutput(line); segmentCount > 0 { log.Println(segmentCount / segment) progressBar.SetValue(segmentCount / segment) } // 检查是否输出了完成的标志 if checkFFmpegCompletion(line) { completed = true endTime := time.Now() // 记录完成时间 duration := endTime.Sub(startTime) // 计算耗时 formattedDuration := formatDuration(duration) // 格式化显示耗时 progressBar.SetValue(1.0) // 设置进度条到最大值 fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: "HLS-builder 视频切片工具", Content: fmt.Sprintf("✅ 视频切片完成! 总用时: %s\n存储位置:%s", formattedDuration, outputDir), }) dialog.ShowInformation("完成", fmt.Sprintf("✅ 视频切片完成! 总用时: %s\n存储位置:%s", formattedDuration, outputDir), myWindow) log.Println("视频切片完成") break } } if err := cmd.Wait(); err != nil { log.Fatalf("cmd.Wait() 失败: %s\n", err) } if !completed { log.Println("❌ 视频切片可能未正确完成") } onComplete() }() } // getTotalDuration extracts total duration of the video file using FFmpeg func getTotalDuration(file string) float64 { ffmpegPath := getFFmpegPath() // 构建和启动 ffmpeg 命令 cmd := exec.Command(ffmpegPath, "-i", file) // 由于 ffmpeg 在只有 '-i' 时输出到 stderr,因此需要捕获 stderr stderr, err := cmd.StderrPipe() if err != nil { log.Fatal("Failed to get stderr pipe:", err) } if err := cmd.Start(); err != nil { log.Println("Failed to start ffmpeg:", err) return 0.0 // 这里选择不是致命错误,只是返回0.0表示无法获取时长 } // 使用 Scanner 来读取 stderr scanner := bufio.NewScanner(stderr) durationRegex := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`) var duration float64 = 0.0 for scanner.Scan() { matches := durationRegex.FindStringSubmatch(scanner.Text()) if matches != nil { hours, _ := strconv.Atoi(matches[1]) minutes, _ := strconv.Atoi(matches[2]) seconds, _ := strconv.ParseFloat(matches[3], 64) duration = float64(hours)*3600 + float64(minutes)*60 + seconds break } } // 等待命令完成,忽略错误 cmd.Wait() // 注意这里我们不处理错误,因为 FFmpeg 总是返回错误状态 return duration } func parseFFmpegProgressOutput(line string) float64 { re := regexp.MustCompile(`(\d+)\.ts`) // 正则表达式匹配数字后跟.ts matches := re.FindStringSubmatch(line) if len(matches) > 1 { number, err := strconv.Atoi(matches[1]) // 将提取的数字转换为int if err != nil { log.Println("Error converting string to integer:", err) return 0 } return float64(number) } return 0 // 如果没有找到匹配,返回0 } func checkFFmpegCompletion(line string) bool { // 记录接收到的行,以便于调试 log.Println("line:", line) // 正则表达式匹配含有 "out#0/hls" 的行,这通常在 FFmpeg 输出最后阶段显示 re := regexp.MustCompile(`out#0/hls`) // 检查这一行是否匹配正则表达式 matchFound := re.MatchString(line) log.Println("re", matchFound) return matchFound } func formatDuration(d time.Duration) string { totalSeconds := int(d.Seconds()) hours := totalSeconds / 3600 minutes := (totalSeconds % 3600) / 60 seconds := totalSeconds % 60 milliseconds := int(d.Milliseconds()) % 1000 // 获取剩余的毫秒数 result := "" if hours > 0 { result += fmt.Sprintf("%d小时", hours) } if minutes > 0 || hours > 0 { // 显示分钟,如果有小时无论如何都显示 result += fmt.Sprintf("%d分", minutes) } // 只在秒大于0或小时和分钟都为0时显示秒 if seconds > 0 || (hours != 0 && minutes != 0) { result += fmt.Sprintf("%d秒", seconds) } result += fmt.Sprintf("%d毫秒", milliseconds) return result } // 上传到Ucloud func upload(localPath string, keyPath string, onComplete func()) { fmt.Println("上传到Ucloud") fmt.Println("localPath:", localPath) fmt.Println("keyPath:", keyPath) us3cliPath := getUs3cliPath() bucket := "us3://jlntv-live/replay/" + keyPath cmd := exec.Command(us3cliPath, "cp", localPath, bucket, "-r", "--parallel", "10", "--reduce") // 获取命令的标准输出和错误输出 stdout, err := cmd.StdoutPipe() if err != nil { log.Fatal("Error creating stdout pipe:", err) } stderr, err := cmd.StderrPipe() if err != nil { log.Fatal("Error creating stderr pipe:", err) } // 开始执行命令 if err := cmd.Start(); err != nil { log.Fatalf("Failed to start command: %s", err) } // 使用协程异步读取输出 go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { fmt.Println("STDOUT:", scanner.Text()) } }() go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { fmt.Println("STDERR:", scanner.Text()) } }() // 等待命令执行完成 if err := cmd.Wait(); err != nil { log.Printf("Command finished with error: %v", err) } if onComplete != nil { onComplete() } }