diff --git a/wails3-app/.gitignore b/wails3-app/.gitignore new file mode 100644 index 0000000..e68f2d4 --- /dev/null +++ b/wails3-app/.gitignore @@ -0,0 +1,30 @@ +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Frontend +node_modules/ +dist/ +build/ +*.log + +# Assets (generated) +assets/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + diff --git a/wails3-app/README.md b/wails3-app/README.md new file mode 100644 index 0000000..96fa40e --- /dev/null +++ b/wails3-app/README.md @@ -0,0 +1,121 @@ +# VideoConcat - Wails3 版本 + +这是使用 Wails3 重构的视频拼接工具。 + +## 项目结构 + +``` +wails3-app/ +├── app.go # 应用入口 +├── go.mod # Go 模块文件 +├── services/ # 后端服务 +│ ├── video_service.go # 视频处理服务 +│ ├── extract_service.go # 抽帧服务 +│ ├── auth_service.go # 认证服务 +│ ├── file_service.go # 文件服务 +│ └── log.go # 日志工具 +├── frontend/ # 前端代码 +│ ├── package.json +│ ├── vite.config.js +│ ├── index.html +│ └── src/ +│ ├── main.js +│ ├── App.vue +│ └── components/ +│ ├── VideoTab.vue +│ ├── ExtractTab.vue +│ └── LoginDialog.vue +└── assets/ # 编译后的前端资源(自动生成) +``` + +## 环境要求 + +- Go 1.21 或更高版本 +- Node.js 16 或更高版本 +- FFmpeg(需要安装并添加到系统 PATH) + +## 安装步骤 + +### 1. 安装 Wails3 + +```bash +# 克隆 Wails 仓库 +git clone https://github.com/wailsapp/wails.git +cd wails +git checkout v3-alpha + +# 安装 Wails3 CLI +cd v3/cmd/wails3 +go install +``` + +### 2. 安装前端依赖 + +```bash +cd frontend +npm install +``` + +### 3. 构建前端 + +```bash +npm run build +``` + +### 4. 运行应用 + +```bash +# 在项目根目录 +go run app.go +``` + +## 功能说明 + +### 视频拼接 + +1. **组合拼接模式**:从每个文件夹随机选择视频进行组合 +2. **顺序拼接模式**:按索引顺序从每个文件夹选择对应位置的视频 + +### 视频抽帧 + +- 随机删除视频中的一帧 +- 支持批量处理 +- 自动处理 HEVC 编码 + +### 视频元数据修改 + +- 通过修改视频元数据改变文件 MD5 +- 不重新编码视频,处理速度快 + +## 注意事项 + +1. 确保 FFmpeg 已安装并可在命令行中使用 +2. 视频处理需要足够的磁盘空间 +3. 临时文件存储在系统临时目录 + +## 开发说明 + +### 后端服务 + +所有后端服务都在 `services/` 目录下,使用 Go 编写。 + +### 前端 + +前端使用 Vue3 + Vite 构建,代码在 `frontend/` 目录下。 + +### 前后端通信 + +Wails3 使用自动绑定机制,后端服务通过 `app.Bind()` 绑定后,前端可以通过 `window.go.services.ServiceName` 访问。 + +## 已知问题 + +1. 文件选择对话框在浏览器环境中需要使用 HTML5 API,实际部署时可能需要使用 Wails3 的文件对话框 API +2. 进度回调函数需要根据 Wails3 的实际 API 进行调整 + +## 后续改进 + +1. 添加视频预览功能 +2. 优化错误处理和用户提示 +3. 添加更多视频格式支持 +4. 优化 UI 设计 + diff --git a/wails3-app/app.go b/wails3-app/app.go new file mode 100644 index 0000000..acba917 --- /dev/null +++ b/wails3-app/app.go @@ -0,0 +1,50 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v3/pkg/application" + "videoconcat/services" +) + +//go:embed assets +var assets embed.FS + +func main() { + app := application.New(application.Options{ + Name: "VideoConcat", + Description: "视频拼接工具", + Assets: application.AssetOptions{FS: assets}, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: true, + }, + }) + + // 创建服务 + videoService := services.NewVideoService() + extractService := services.NewExtractService() + authService := services.NewAuthService() + fileService := services.NewFileService() + + // 绑定服务到前端 + app.Bind(videoService) + app.Bind(extractService) + app.Bind(authService) + app.Bind(fileService) + + // 创建窗口 + window := app.NewWebviewWindow(application.WebviewWindowOptions{ + Title: "视频拼接工具", + Width: 1100, + Height: 800, + MinWidth: 800, + MinHeight: 600, + }) + + window.SetURL("index.html") + + if err := app.Run(); err != nil { + panic(err) + } +} + diff --git a/wails3-app/frontend/index.html b/wails3-app/frontend/index.html new file mode 100644 index 0000000..a69bfcc --- /dev/null +++ b/wails3-app/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + 视频拼接工具 + + + +
+ + + + diff --git a/wails3-app/frontend/package.json b/wails3-app/frontend/package.json new file mode 100644 index 0000000..99ce6e3 --- /dev/null +++ b/wails3-app/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "videoconcat-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} + diff --git a/wails3-app/frontend/src/App.vue b/wails3-app/frontend/src/App.vue new file mode 100644 index 0000000..ed3bff4 --- /dev/null +++ b/wails3-app/frontend/src/App.vue @@ -0,0 +1,115 @@ + + + + + + diff --git a/wails3-app/frontend/src/components/ExtractTab.vue b/wails3-app/frontend/src/components/ExtractTab.vue new file mode 100644 index 0000000..9ce181e --- /dev/null +++ b/wails3-app/frontend/src/components/ExtractTab.vue @@ -0,0 +1,334 @@ + + + + + + diff --git a/wails3-app/frontend/src/components/LoginDialog.vue b/wails3-app/frontend/src/components/LoginDialog.vue new file mode 100644 index 0000000..e84767e --- /dev/null +++ b/wails3-app/frontend/src/components/LoginDialog.vue @@ -0,0 +1,200 @@ + + + + + + diff --git a/wails3-app/frontend/src/components/VideoTab.vue b/wails3-app/frontend/src/components/VideoTab.vue new file mode 100644 index 0000000..51ac63e --- /dev/null +++ b/wails3-app/frontend/src/components/VideoTab.vue @@ -0,0 +1,403 @@ + + + + + + diff --git a/wails3-app/frontend/src/main.js b/wails3-app/frontend/src/main.js new file mode 100644 index 0000000..eaa7028 --- /dev/null +++ b/wails3-app/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') + diff --git a/wails3-app/frontend/vite.config.js b/wails3-app/frontend/vite.config.js new file mode 100644 index 0000000..251fc38 --- /dev/null +++ b/wails3-app/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + build: { + outDir: '../assets', + emptyOutDir: true, + }, + base: './', +}) + diff --git a/wails3-app/go.mod b/wails3-app/go.mod new file mode 100644 index 0000000..419ce59 --- /dev/null +++ b/wails3-app/go.mod @@ -0,0 +1,12 @@ +module videoconcat + +go 1.21 + +require ( + github.com/wailsapp/wails/v3 v3.0.0-alpha.57 +) + +require ( + github.com/google/uuid v1.3.0 // indirect +) + diff --git a/wails3-app/services/auth_service.go b/wails3-app/services/auth_service.go new file mode 100644 index 0000000..fc500b7 --- /dev/null +++ b/wails3-app/services/auth_service.go @@ -0,0 +1,104 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "time" +) + +// AuthService 认证服务 +type AuthService struct { + baseURL string + client *http.Client +} + +// NewAuthService 创建认证服务实例 +func NewAuthService() *AuthService { + return &AuthService{ + baseURL: "https://admin.xiangbing.vip", + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"Username"` + Password string `json:"Password"` + Platform string `json:"Platform"` + PcName string `json:"PcName"` + PcUserName string `json:"PcUserName"` + Ips string `json:"Ips"` +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Code int `json:"Code"` + Msg string `json:"Msg"` + Data interface{} `json:"Data"` +} + +// Login 用户登录 +func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) { + // 获取机器信息 + pcMachineName, _ := os.Hostname() + pcUserName := os.Getenv("USERNAME") + if pcUserName == "" { + pcUserName = os.Getenv("USER") + } + + // 获取 IP 地址 + var ips string + addrs, err := net.InterfaceAddrs() + if err == nil { + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + ips = ipnet.IP.String() + break + } + } + } + } + + reqData := LoginRequest{ + Username: username, + Password: password, + Platform: "pc", + PcName: pcMachineName, + PcUserName: pcUserName, + Ips: ips, + } + + jsonData, err := json.Marshal(reqData) + if err != nil { + return nil, fmt.Errorf("序列化请求数据失败: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/api/base/login", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + + var loginResp LoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + return &loginResp, nil +} + diff --git a/wails3-app/services/extract_service.go b/wails3-app/services/extract_service.go new file mode 100644 index 0000000..12804e0 --- /dev/null +++ b/wails3-app/services/extract_service.go @@ -0,0 +1,318 @@ +package services + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// ExtractService 抽帧服务 +type ExtractService struct { + mu sync.Mutex +} + +// NewExtractService 创建抽帧服务实例 +func NewExtractService() *ExtractService { + return &ExtractService{} +} + +// ExtractFrameRequest 抽帧请求 +type ExtractFrameRequest struct { + FolderPath string `json:"folderPath"` + ExtractCount int `json:"extractCount"` // 每个视频生成的数量 +} + +// ExtractFrameResult 抽帧结果 +type ExtractFrameResult struct { + VideoPath string `json:"videoPath"` + OutputPath string `json:"outputPath"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// ListVideos 列出文件夹中的视频文件 +func (s *ExtractService) ListVideos(ctx context.Context, folderPath string) ([]string, error) { + pattern := filepath.Join(folderPath, "*.mp4") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("查找视频文件失败: %v", err) + } + return matches, nil +} + +// RemoveFrameRandom 随机删除视频中的一帧 +func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string, outputPath string) error { + // 创建临时目录 + tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("extract_%d", time.Now().UnixNano())) + defer os.RemoveAll(tempDir) + os.MkdirAll(tempDir, 0755) + + // 获取视频信息 + cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("获取视频信息失败: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var duration float64 + var codecName string + var frameRate float64 = 30.0 // 默认帧率 + + for i, line := range lines { + line = strings.TrimSpace(line) + if i == 0 && line != "" { + fmt.Sscanf(line, "%f", &duration) + } + if strings.HasPrefix(line, "hevc") || strings.HasPrefix(line, "h264") { + codecName = line + } + if strings.Contains(line, "/") { + parts := strings.Split(line, "/") + if len(parts) == 2 { + var num, den float64 + fmt.Sscanf(parts[0], "%f", &num) + fmt.Sscanf(parts[1], "%f", &den) + if den > 0 { + frameRate = num / den + } + } + } + } + + // 检查时长 + if duration < 20 { + return fmt.Errorf("视频时长太短(%.2f秒),无法抽帧(需要至少20秒)", duration) + } + + // 如果是 HEVC,先转换为 H.264 + if codecName == "hevc" { + videoConvert := filepath.Join(tempDir, "convert.mp4") + cmd := exec.Command("ffmpeg", "-i", inputPath, "-c:v", "libx264", "-y", videoConvert) + if err := cmd.Run(); err != nil { + return fmt.Errorf("转换HEVC失败: %v", err) + } + inputPath = videoConvert + } + + // 随机选择要删除的帧时间点(避开开头和结尾) + rand.Seed(time.Now().UnixNano()) + minFrameTime := 20.0 + maxFrameTime := duration - 5.0 + if maxFrameTime <= minFrameTime { + maxFrameTime = minFrameTime + 1.0 + } + randomFrame := minFrameTime + rand.Float64()*(maxFrameTime-minFrameTime) + + // 分割视频 + videoPart1 := filepath.Join(tempDir, "part1.mp4") + videoPart2 := filepath.Join(tempDir, "part2.mp4") + + // 第一部分:0 到 randomFrame - 0.016 + cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1) + if err := cmd.Run(); err != nil { + return fmt.Errorf("裁剪第一部分失败: %v", err) + } + + // 第二部分:randomFrame 到结束 + cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2) + if err := cmd.Run(); err != nil { + return fmt.Errorf("裁剪第二部分失败: %v", err) + } + + // 合并视频 + concatFile := filepath.Join(tempDir, "concat.txt") + file, err := os.Create(concatFile) + if err != nil { + return fmt.Errorf("创建concat文件失败: %v", err) + } + file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart1, "\\", "/"))) + file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/"))) + file.Close() + + cmd = exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath) + if err := cmd.Run(); err != nil { + return fmt.Errorf("合并视频失败: %v", err) + } + + // 验证输出文件 + if _, err := os.Stat(outputPath); err != nil { + return fmt.Errorf("输出文件不存在: %v", err) + } + + return nil +} + +// ExtractFrames 批量抽帧 +func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequest) ([]ExtractFrameResult, error) { + var results []ExtractFrameResult + // 列出所有视频 + videos, err := s.ListVideos(ctx, req.FolderPath) + if err != nil { + return nil, err + } + + if len(videos) == 0 { + return nil, fmt.Errorf("没有找到视频文件") + } + + // 创建输出目录 + outputDir := filepath.Join(req.FolderPath, "out") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("创建输出目录失败: %v", err) + } + + // 生成任务列表 + type Task struct { + VideoPath string + Index int + OutputPath string + } + + var tasks []Task + for _, video := range videos { + originalFileName := strings.TrimSuffix(filepath.Base(video), filepath.Ext(video)) + extension := filepath.Ext(video) + for i := 1; i <= req.ExtractCount; i++ { + outputFileName := fmt.Sprintf("%s_%04d%s", originalFileName, i, extension) + outputPath := filepath.Join(outputDir, outputFileName) + tasks = append(tasks, Task{ + VideoPath: video, + Index: i, + OutputPath: outputPath, + }) + } + } + + // 并发处理(限制并发数) + semaphore := make(chan struct{}, 10) + var wg sync.WaitGroup + total := len(tasks) + current := 0 + var mu sync.Mutex + + for _, task := range tasks { + wg.Add(1) + go func(t Task) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + // 检查文件是否已存在 + if _, err := os.Stat(t.OutputPath); err == nil { + mu.Lock() + current++ + result := ExtractFrameResult{ + VideoPath: t.VideoPath, + OutputPath: t.OutputPath, + Success: true, + } + results = append(results, result) + mu.Unlock() + return + } + + err := s.RemoveFrameRandom(ctx, t.VideoPath, t.OutputPath) + mu.Lock() + current++ + var result ExtractFrameResult + if err != nil { + result = ExtractFrameResult{ + VideoPath: t.VideoPath, + Success: false, + Error: err.Error(), + } + } else { + result = ExtractFrameResult{ + VideoPath: t.VideoPath, + OutputPath: t.OutputPath, + Success: true, + } + } + results = append(results, result) + mu.Unlock() + }(task) + } + + wg.Wait() + return results, nil +} + +// ModifyByMetadata 通过修改元数据改变文件 MD5 +func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error { + comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405")) + cmd := exec.Command("ffmpeg", "-i", inputPath, + "-c", "copy", + "-metadata", fmt.Sprintf("comment=%s", comment), + "-y", outputPath) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("修改元数据失败: %v", err) + } + + return nil +} + +// ModifyVideosMetadata 批量修改视频元数据 +func (s *ExtractService) ModifyVideosMetadata(ctx context.Context, folderPath string) ([]ExtractFrameResult, error) { + var results []ExtractFrameResult + videos, err := s.ListVideos(ctx, folderPath) + if err != nil { + return nil, err + } + + outputDir := filepath.Join(folderPath, "out") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("创建输出目录失败: %v", err) + } + + semaphore := make(chan struct{}, 10) + var wg sync.WaitGroup + total := len(videos) + current := 0 + var mu sync.Mutex + + for _, video := range videos { + wg.Add(1) + go func(videoPath string) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + rand.Seed(time.Now().UnixNano()) + randomNum := rand.Intn(90000) + 10000 + outputFileName := fmt.Sprintf("modify%d%s", randomNum, filepath.Base(videoPath)) + outputPath := filepath.Join(outputDir, outputFileName) + + err := s.ModifyByMetadata(ctx, videoPath, outputPath) + mu.Lock() + current++ + var result ExtractFrameResult + if err != nil { + result = ExtractFrameResult{ + VideoPath: videoPath, + Success: false, + Error: err.Error(), + } + } else { + result = ExtractFrameResult{ + VideoPath: videoPath, + OutputPath: outputPath, + Success: true, + } + } + results = append(results, result) + mu.Unlock() + }(video) + } + + wg.Wait() + return results, nil +} + diff --git a/wails3-app/services/file_service.go b/wails3-app/services/file_service.go new file mode 100644 index 0000000..e2ff66e --- /dev/null +++ b/wails3-app/services/file_service.go @@ -0,0 +1,76 @@ +package services + +import ( + "context" + "os" + "path/filepath" +) + +// FileService 文件服务 +type FileService struct{} + +// NewFileService 创建文件服务实例 +func NewFileService() *FileService { + return &FileService{} +} + +// SelectFolder 选择文件夹(返回路径) +func (s *FileService) SelectFolder(ctx context.Context) (string, error) { + // 在 Wails3 中,文件选择需要通过前端实现 + // 这里只是占位,实际应该通过前端调用系统对话框 + return "", nil +} + +// SelectFile 选择文件(返回路径) +func (s *FileService) SelectFile(ctx context.Context, filter string) (string, error) { + // 在 Wails3 中,文件选择需要通过前端实现 + return "", nil +} + +// OpenFolder 打开文件夹 +func (s *FileService) OpenFolder(ctx context.Context, folderPath string) error { + // Windows 下打开文件夹 + cmd := "explorer" + args := []string{folderPath} + + // 这里需要使用 exec.Command,但为了简化,我们返回路径让前端处理 + // 或者使用系统调用 + return nil +} + +// FileExists 检查文件是否存在 +func (s *FileService) FileExists(ctx context.Context, filePath string) (bool, error) { + _, err := os.Stat(filePath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// GetFileSize 获取文件大小(字节) +func (s *FileService) GetFileSize(ctx context.Context, filePath string) (int64, error) { + info, err := os.Stat(filePath) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// EnsureDirectory 确保目录存在 +func (s *FileService) EnsureDirectory(ctx context.Context, dirPath string) error { + return os.MkdirAll(dirPath, 0755) +} + +// ListFiles 列出目录中的文件 +func (s *FileService) ListFiles(ctx context.Context, dirPath string, pattern string) ([]string, error) { + patternPath := filepath.Join(dirPath, pattern) + matches, err := filepath.Glob(patternPath) + if err != nil { + return nil, err + } + return matches, nil +} + diff --git a/wails3-app/services/log.go b/wails3-app/services/log.go new file mode 100644 index 0000000..15e463e --- /dev/null +++ b/wails3-app/services/log.go @@ -0,0 +1,60 @@ +package services + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +var logDir string + +func init() { + // 设置日志目录 + exePath, err := os.Executable() + if err != nil { + logDir = "./Log" + } else { + logDir = filepath.Join(filepath.Dir(exePath), "Log") + } + os.MkdirAll(logDir, 0755) +} + +// LogInfo 记录信息日志 +func LogInfo(message string) { + logMessage("INFO", message) +} + +// LogError 记录错误日志 +func LogError(message string) { + logMessage("ERROR", message) +} + +// LogWarn 记录警告日志 +func LogWarn(message string) { + logMessage("WARN", message) +} + +// LogDebug 记录调试日志 +func LogDebug(message string) { + logMessage("DEBUG", message) +} + +// logMessage 写入日志消息 +func logMessage(level, message string) { + // 控制台输出 + log.Printf("[%s] %s", level, message) + + // 文件输出 + logFile := filepath.Join(logDir, fmt.Sprintf("log%s.log", time.Now().Format("20060102"))) + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return + } + defer file.Close() + + timestamp := time.Now().Format("15:04:05") + file.WriteString(fmt.Sprintf("%s [%s] %s\n", timestamp, level, message)) +} + diff --git a/wails3-app/services/video_service.go b/wails3-app/services/video_service.go new file mode 100644 index 0000000..62f74f0 --- /dev/null +++ b/wails3-app/services/video_service.go @@ -0,0 +1,413 @@ +package services + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// VideoService 视频处理服务 +type VideoService struct { + mu sync.Mutex +} + +// NewVideoService 创建视频服务实例 +func NewVideoService() *VideoService { + return &VideoService{} +} + +// FolderInfo 文件夹信息 +type FolderInfo struct { + Path string `json:"path"` + Name string `json:"name"` + VideoCount int `json:"videoCount"` + VideoPaths []string `json:"videoPaths"` +} + +// VideoConcatRequest 视频拼接请求 +type VideoConcatRequest struct { + FolderPath string `json:"folderPath"` + Num int `json:"num"` + JoinType int `json:"joinType"` // 1: 组合拼接, 2: 顺序拼接 + AuditImagePath string `json:"auditImagePath"` + FolderInfos []FolderInfo `json:"folderInfos"` +} + +// VideoConcatResult 视频拼接结果 +type VideoConcatResult struct { + Index int `json:"index"` + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + Size string `json:"size"` + Seconds int `json:"seconds"` + Status string `json:"status"` + Progress string `json:"progress"` +} + +// ListFolders 列出文件夹中的视频文件夹 +func (s *VideoService) ListFolders(ctx context.Context, folderPath string) ([]FolderInfo, error) { + var folderInfos []FolderInfo + + dir, err := os.ReadDir(folderPath) + if err != nil { + return nil, fmt.Errorf("读取目录失败: %v", err) + } + + for _, entry := range dir { + if !entry.IsDir() || entry.Name() == "output" { + continue + } + + fullPath := filepath.Join(folderPath, entry.Name()) + videoFiles, err := filepath.Glob(filepath.Join(fullPath, "*.mp4")) + if err != nil { + continue + } + + folderInfos = append(folderInfos, FolderInfo{ + Path: fullPath, + Name: entry.Name(), + VideoCount: len(videoFiles), + VideoPaths: videoFiles, + }) + } + + return folderInfos, nil +} + +// GetLargeFileMD5 计算大文件的 MD5 +func (s *VideoService) GetLargeFileMD5(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := md5.New() + buffer := make([]byte, 8192) // 8KB 缓冲区 + + for { + n, err := file.Read(buffer) + if n > 0 { + hash.Write(buffer[:n]) + } + if err == io.EOF { + break + } + if err != nil { + return "", err + } + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// ConvertVideoToTS 将视频转换为 TS 格式 +func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) { + // 计算 MD5 + md5Hash, err := s.GetLargeFileMD5(videoPath) + if err != nil { + return "", fmt.Errorf("计算MD5失败: %v", err) + } + + // 临时文件路径 + tempDir := os.TempDir() + tsPath := filepath.Join(tempDir, md5Hash+".ts") + + // 如果文件已存在,直接返回 + if _, err := os.Stat(tsPath); err == nil { + return tsPath, nil + } + + // 使用 FFmpeg 转换 + cmd := exec.Command("ffmpeg", "-i", videoPath, + "-c:v", "libx264", + "-c:a", "aac", + "-ar", "44100", + "-b:a", "128k", + "-crf", "23", + "-vf", "fps=30", + "-movflags", "+faststart", + "-f", "mpegts", + "-y", tsPath) + + output, err := cmd.CombinedOutput() + if err != nil { + // 尝试备用方案 + cmd2 := exec.Command("ffmpeg", "-i", videoPath, + "-c", "copy", + "-f", "mpegts", + "-y", tsPath) + output2, err2 := cmd2.CombinedOutput() + if err2 != nil { + return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2)) + } + } + + return tsPath, nil +} + +// GenerateCombinations 生成所有视频组合(笛卡尔积) +func (s *VideoService) GenerateCombinations(videoLists [][]string, index int, currentCombination []string, result *[][]string) { + if index == len(videoLists) { + combination := make([]string, len(currentCombination)) + copy(combination, currentCombination) + *result = append(*result, combination) + return + } + + for _, video := range videoLists[index] { + currentCombination = append(currentCombination, video) + s.GenerateCombinations(videoLists, index+1, currentCombination, result) + currentCombination = currentCombination[:len(currentCombination)-1] + } +} + +// JoinVideos 拼接视频 +// 注意:Wails3 中回调函数需要使用事件系统,这里先简化处理 +func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) ([]VideoConcatResult, error) { + var results []VideoConcatResult + // 创建输出目录 + outputDir := filepath.Join(req.FolderPath, "output") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("创建输出目录失败: %v", err) + } + + // 准备视频列表 + var videoLists [][]string + for _, folderInfo := range req.FolderInfos { + videoLists = append(videoLists, folderInfo.VideoPaths) + } + + var combinations [][]string + + if req.JoinType == 1 { + // 组合拼接模式 + s.GenerateCombinations(videoLists, 0, []string{}, &combinations) + + // 随机选择指定数量的组合 + if req.Num < len(combinations) { + rand.Seed(time.Now().UnixNano()) + selected := make(map[int]bool) + newCombinations := [][]string{} + for len(newCombinations) < req.Num { + idx := rand.Intn(len(combinations)) + if !selected[idx] { + selected[idx] = true + newCombinations = append(newCombinations, combinations[idx]) + } + } + combinations = newCombinations + } + } else if req.JoinType == 2 { + // 顺序拼接模式 + if len(videoLists) == 0 { + return fmt.Errorf("没有视频文件") + } + count := len(videoLists[0]) + for i := 1; i < len(videoLists); i++ { + if len(videoLists[i]) != count { + return fmt.Errorf("所有文件夹中的视频数量必须相同") + } + } + + for i := 0; i < count; i++ { + combination := []string{} + for _, videoList := range videoLists { + combination = append(combination, videoList[i]) + } + combinations = append(combinations, combination) + } + } + + // 先转换所有视频为 TS 格式 + var allVideoPaths []string + for _, combination := range combinations { + allVideoPaths = append(allVideoPaths, combination...) + } + + // 去重 + uniqueVideos := make(map[string]bool) + var uniqueVideoPaths []string + for _, path := range allVideoPaths { + if !uniqueVideos[path] { + uniqueVideos[path] = true + uniqueVideoPaths = append(uniqueVideoPaths, path) + } + } + + // 并发转换视频(限制并发数) + semaphore := make(chan struct{}, 10) + var wg sync.WaitGroup + tsMap := make(map[string]string) + var mu sync.Mutex + + for _, videoPath := range uniqueVideoPaths { + wg.Add(1) + go func(path string) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + tsPath, err := s.ConvertVideoToTS(path) + if err != nil { + LogError(fmt.Sprintf("转换视频失败: %v", err)) + return + } + + mu.Lock() + tsMap[path] = tsPath + mu.Unlock() + }(videoPath) + } + wg.Wait() + + // 拼接视频 + semaphore = make(chan struct{}, 10) + var mu sync.Mutex + for i, combination := range combinations { + wg.Add(1) + go func(index int, combo []string) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + // 获取第一个视频的文件名 + firstVideoName := strings.TrimSuffix(filepath.Base(combo[0]), filepath.Ext(combo[0])) + outputFileName := fmt.Sprintf("%s_%04d.mp4", firstVideoName, index+1) + outputPath := filepath.Join(outputDir, outputFileName) + + // 创建结果对象 + result := VideoConcatResult{ + Index: index + 1, + FileName: outputFileName, + FilePath: outputPath, + Status: "处理中", + Progress: "0%", + } + + // 获取 TS 文件路径 + var tsPaths []string + for _, videoPath := range combo { + if tsPath, ok := tsMap[videoPath]; ok { + tsPaths = append(tsPaths, tsPath) + } else { + LogError(fmt.Sprintf("找不到视频的TS文件: %s", videoPath)) + result.Status = "拼接失败" + result.Progress = "失败" + mu.Lock() + results = append(results, result) + mu.Unlock() + return + } + } + + // 创建 concat 文件 + concatFile := filepath.Join(os.TempDir(), fmt.Sprintf("concat_%d_%d.txt", time.Now().Unix(), index)) + file, err := os.Create(concatFile) + if err != nil { + LogError(fmt.Sprintf("创建concat文件失败: %v", err)) + result.Status = "拼接失败" + result.Progress = "失败" + mu.Lock() + results = append(results, result) + mu.Unlock() + return + } + + for _, tsPath := range tsPaths { + file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(tsPath, "\\", "/"))) + } + file.Close() + defer os.Remove(concatFile) + + // 拼接视频 + args := []string{ + "-f", "concat", + "-safe", "0", + "-i", concatFile, + "-c:v", "copy", + "-c:a", "aac", + "-ar", "44100", + "-movflags", "+faststart", + "-analyzeduration", "100M", + "-probesize", "100M", + "-y", outputPath, + } + + cmd := exec.Command("ffmpeg", args...) + err = cmd.Run() + if err != nil { + LogError(fmt.Sprintf("拼接视频失败: %v", err)) + result.Status = "拼接失败" + result.Progress = "失败" + mu.Lock() + results = append(results, result) + mu.Unlock() + return + } + + // 如果指定了审核图片,添加水印 + if req.AuditImagePath != "" { + outputImgPath := filepath.Join(outputDir, fmt.Sprintf("%s_%04d_img.mp4", firstVideoName, index+1)) + args := []string{ + "-i", outputPath, + "-i", req.AuditImagePath, + "-filter_complex", "[0:v][1:v] overlay=0:H-h", + "-movflags", "+faststart", + "-y", outputImgPath, + } + + cmd := exec.Command("ffmpeg", args...) + cmd.Run() // 忽略错误,水印是可选的 + } + + // 获取文件信息 + fileInfo, err := os.Stat(outputPath) + if err != nil { + progressCallback(VideoConcatResult{ + Index: index + 1, + FileName: outputFileName, + Status: "拼接失败", + Progress: "失败", + }) + return + } + + // 获取视频时长(使用 ffprobe) + seconds := 0 + cmd = exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath) + output, err := cmd.Output() + if err == nil { + var duration float64 + fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration) + seconds = int(duration) + } + + sizeMB := fileInfo.Size() / 1024 / 1024 + + result.FilePath = outputPath + result.Size = fmt.Sprintf("%dMB", sizeMB) + result.Seconds = seconds + result.Status = "拼接成功" + result.Progress = "100%" + + mu.Lock() + results = append(results, result) + mu.Unlock() + }(i, combination) + } + + wg.Wait() + return results, nil +} + diff --git a/wails3-app/wails.json b/wails3-app/wails.json new file mode 100644 index 0000000..65e4f0a --- /dev/null +++ b/wails3-app/wails.json @@ -0,0 +1,23 @@ +{ + "name": "VideoConcat", + "outputfilename": "videoconcat", + "frontend": { + "dir": "./frontend", + "install": "npm install", + "build": "npm run build", + "bridge": "src", + "serve": "npm run dev" + }, + "author": { + "name": "", + "email": "" + }, + "info": { + "companyName": "", + "productName": "VideoConcat", + "productVersion": "1.0.0", + "copyright": "Copyright © 2024", + "comments": "视频拼接工具" + } +} + diff --git a/wails3-app/快速开始.md b/wails3-app/快速开始.md new file mode 100644 index 0000000..560cbbb --- /dev/null +++ b/wails3-app/快速开始.md @@ -0,0 +1,100 @@ +# 快速开始指南 + +## 解决 wails.json 错误 + +如果遇到 "wails.json: The system cannot find the file specified" 错误,请按以下步骤操作: + +### 步骤 1: 安装前端依赖 + +```powershell +cd frontend +npm install +``` + +### 步骤 2: 构建前端 + +```powershell +npm run build +``` + +这会生成 `assets` 目录和所有前端资源。 + +### 步骤 3: 运行应用 + +```powershell +cd .. +go run app.go +``` + +## 如果仍然遇到问题 + +### 检查文件是否存在 + +确保以下文件存在: +- `wails.json` ✓ (已创建) +- `assets/` 目录 ✓ (已创建) +- `assets/index.html` ✓ (已创建) + +### 如果 assets 目录为空 + +需要先构建前端: + +```powershell +cd frontend +npm install +npm run build +cd .. +``` + +### 验证 Go 模块 + +```powershell +go mod tidy +go mod download +``` + +### 检查 Wails3 版本 + +确保使用的是正确的 Wails3 版本。如果使用的是 Wails v2,配置方式可能不同。 + +## 开发模式 + +### 前端开发(热重载) + +```powershell +cd frontend +npm run dev +``` + +### 后端开发 + +在另一个终端: + +```powershell +go run app.go +``` + +## 常见错误解决 + +### 1. "module not found" + +```powershell +go mod tidy +go mod download +``` + +### 2. "assets not found" + +确保已构建前端: +```powershell +cd frontend +npm run build +``` + +### 3. "ffmpeg not found" + +确保 FFmpeg 已安装并在 PATH 中: +```powershell +ffmpeg -version +``` + diff --git a/wails3-app/构建说明.md b/wails3-app/构建说明.md new file mode 100644 index 0000000..cef978c --- /dev/null +++ b/wails3-app/构建说明.md @@ -0,0 +1,151 @@ +# 构建和运行说明 + +## 前置要求 + +1. **Go 1.21+** + ```bash + go version + ``` + +2. **Node.js 16+** + ```bash + node --version + npm --version + ``` + +3. **FFmpeg** + ```bash + ffmpeg -version + ``` + +4. **Wails3 CLI** + ```bash + # 安装 Wails3(如果还没有) + git clone https://github.com/wailsapp/wails.git + cd wails + git checkout v3-alpha + cd v3/cmd/wails3 + go install + ``` + +## 构建步骤 + +### 1. 安装前端依赖 + +```bash +cd frontend +npm install +``` + +### 2. 构建前端 + +```bash +npm run build +``` + +这会将前端代码构建到 `../assets` 目录。 + +### 3. 运行应用 + +```bash +cd .. +go run app.go +``` + +### 4. 构建可执行文件(可选) + +```bash +go build -o videoconcat.exe app.go +``` + +## 开发模式 + +### 前端开发 + +```bash +cd frontend +npm run dev +``` + +### 后端开发 + +```bash +go run app.go +``` + +## 常见问题 + +### 1. FFmpeg 未找到 + +**错误**: `exec: "ffmpeg": executable file not found in %PATH%` + +**解决**: +- Windows: 下载 FFmpeg 并添加到系统 PATH +- 或者修改代码指定 FFmpeg 完整路径 + +### 2. 前端构建失败 + +**错误**: `npm ERR!` + +**解决**: +```bash +# 清除缓存 +npm cache clean --force +# 删除 node_modules 重新安装 +rm -rf node_modules +npm install +``` + +### 3. Go 模块下载失败 + +**错误**: `go: module ... not found` + +**解决**: +```bash +# 设置 Go 代理(中国用户) +go env -w GOPROXY=https://goproxy.cn,direct +# 或者使用官方代理 +go env -w GOPROXY=https://proxy.golang.org,direct +``` + +### 4. Wails3 绑定失败 + +**错误**: `window.go is undefined` + +**解决**: +- 检查 Wails3 版本和 API +- 确保服务正确绑定 +- 查看浏览器控制台错误信息 + +## 调试 + +### 查看日志 + +日志文件位于:`Log/logYYYYMMDD.log` + +### 浏览器调试 + +1. 打开开发者工具(F12) +2. 查看 Console 标签页的错误信息 +3. 查看 Network 标签页的请求 + +### Go 调试 + +```bash +# 使用 delve 调试器 +go install github.com/go-delve/delve/cmd/dlv@latest +dlv debug app.go +``` + +## 打包发布 + +### Windows + +```bash +go build -ldflags="-H windowsgui" -o videoconcat.exe app.go +``` + +### 其他平台 + +参考 Wails3 官方文档的打包说明。 + diff --git a/wails3-app/迁移说明.md b/wails3-app/迁移说明.md new file mode 100644 index 0000000..64f3c80 --- /dev/null +++ b/wails3-app/迁移说明.md @@ -0,0 +1,166 @@ +# Wails3 重构迁移说明 + +## 已完成的工作 + +### 1. 项目结构 +- ✅ 创建了完整的 Wails3 项目结构 +- ✅ Go 后端服务(services/ 目录) +- ✅ Vue3 前端(frontend/ 目录) + +### 2. 后端功能实现 +- ✅ **VideoService**: 视频拼接服务 + - 列出文件夹中的视频 + - 视频格式转换(MP4 -> TS) + - 组合拼接和顺序拼接 + - MD5 计算和缓存机制 + +- ✅ **ExtractService**: 视频抽帧服务 + - 列出视频文件 + - 随机删除视频中的一帧 + - 批量处理 + - 元数据修改功能 + +- ✅ **AuthService**: 用户认证服务 + - 登录功能 + - 获取机器信息(机器名、用户名、IP) + +- ✅ **FileService**: 文件操作服务 + - 文件存在检查 + - 文件大小获取 + - 目录操作 + +- ✅ **日志系统**: 基于文件的日志记录 + +### 3. 前端功能实现 +- ✅ **主界面**: 侧边栏导航 + 主内容区 +- ✅ **VideoTab**: 视频拼接界面 + - 文件夹选择 + - 拼接模式选择 + - 数量设置 + - 审核图片选择 + - 结果展示 + +- ✅ **ExtractTab**: 视频抽帧界面 + - 文件夹选择 + - 抽帧数量设置 + - 批量处理 + - 元数据修改 + +- ✅ **LoginDialog**: 登录对话框 + +## 主要变化 + +### 后端变化 +1. **语言**: C# -> Go +2. **视频处理**: FFMpegCore -> FFmpeg 命令行调用 +3. **HTTP 客户端**: HttpClient -> Go net/http +4. **日志**: log4net -> 自定义日志系统 +5. **并发**: Task/async -> goroutine/channel + +### 前端变化 +1. **UI 框架**: WPF/XAML -> Vue3/HTML/CSS +2. **数据绑定**: WPF Binding -> Vue3 Reactive +3. **组件化**: WPF UserControl -> Vue Component + +## 需要注意的事项 + +### 1. Wails3 API 兼容性 +当前代码中使用了假设的 Wails3 API: +- `window.go.services.ServiceName` - 需要根据实际 Wails3 API 调整 +- 文件选择对话框 - 需要使用 Wails3 提供的文件对话框 API + +### 2. FFmpeg 路径 +- 确保 FFmpeg 已安装并在系统 PATH 中 +- 或者修改代码指定 FFmpeg 的完整路径 + +### 3. 文件路径处理 +- Windows 路径分隔符需要正确处理 +- 前端传递的路径格式需要与后端兼容 + +### 4. 进度更新 +- 当前实现是批量返回结果,不是实时进度 +- 如需实时进度,需要使用 Wails3 的事件系统 + +### 5. 错误处理 +- 需要完善错误处理和用户提示 +- 添加重试机制 + +## 待完善的功能 + +1. **文件选择对话框** + - 需要使用 Wails3 的原生文件对话框 API + - 当前使用 HTML5 API 作为临时方案 + +2. **进度实时更新** + - 使用 Wails3 事件系统实现实时进度 + - 或者使用 WebSocket/Server-Sent Events + +3. **错误处理** + - 更友好的错误提示 + - 错误日志记录 + +4. **UI 优化** + - 更现代化的界面设计 + - 响应式布局 + - 动画效果 + +5. **性能优化** + - 大文件处理优化 + - 内存使用优化 + - 并发控制优化 + +## 运行步骤 + +1. **安装依赖** + ```bash + # 安装 Wails3(参考 README.md) + # 安装前端依赖 + cd frontend + npm install + ``` + +2. **构建前端** + ```bash + npm run build + ``` + +3. **运行应用** + ```bash + cd .. + go run app.go + ``` + +## 测试建议 + +1. **功能测试** + - 视频拼接(两种模式) + - 视频抽帧 + - 元数据修改 + - 用户登录 + +2. **性能测试** + - 大量视频文件处理 + - 并发处理能力 + - 内存使用情况 + +3. **兼容性测试** + - 不同视频格式 + - 不同操作系统 + - 不同 FFmpeg 版本 + +## 已知问题 + +1. 文件选择在浏览器环境中受限,需要使用 Wails3 原生 API +2. 进度更新是批量返回,不是实时更新 +3. 错误处理需要进一步完善 +4. UI 需要根据实际 Wails3 API 调整 + +## 后续工作 + +1. 根据实际 Wails3 API 调整代码 +2. 实现实时进度更新 +3. 完善错误处理 +4. 优化 UI/UX +5. 添加单元测试 +6. 性能优化 +