414 lines
12 KiB
Go
414 lines
12 KiB
Go
/*
|
||
* @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()
|
||
}
|
||
}
|