update
This commit is contained in:
parent
94b83ce401
commit
7a13a002ba
30
wails3-app/.gitignore
vendored
Normal file
30
wails3-app/.gitignore
vendored
Normal file
@ -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
|
||||||
|
|
||||||
121
wails3-app/README.md
Normal file
121
wails3-app/README.md
Normal file
@ -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 设计
|
||||||
|
|
||||||
50
wails3-app/app.go
Normal file
50
wails3-app/app.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
wails3-app/frontend/index.html
Normal file
29
wails3-app/frontend/index.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>视频拼接工具</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(to bottom, #fefefe, #ededef);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
18
wails3-app/frontend/package.json
Normal file
18
wails3-app/frontend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
115
wails3-app/frontend/src/App.vue
Normal file
115
wails3-app/frontend/src/App.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="menu-items">
|
||||||
|
<button
|
||||||
|
:class="['menu-item', { active: currentTab === 'video' }]"
|
||||||
|
@click="currentTab = 'video'"
|
||||||
|
>
|
||||||
|
<span class="icon">🎬</span>
|
||||||
|
<span>视频</span>
|
||||||
|
</button>
|
||||||
|
<div class="separator"></div>
|
||||||
|
<button
|
||||||
|
:class="['menu-item', { active: currentTab === 'extract' }]"
|
||||||
|
@click="currentTab = 'extract'"
|
||||||
|
>
|
||||||
|
<span class="icon">✂️</span>
|
||||||
|
<span>抽帧</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="menu-bottom">
|
||||||
|
<button class="menu-item" @click="showLogin = true">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main-content">
|
||||||
|
<VideoTab v-if="currentTab === 'video'" />
|
||||||
|
<ExtractTab v-if="currentTab === 'extract'" />
|
||||||
|
</div>
|
||||||
|
<LoginDialog v-if="showLogin" @close="showLogin = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import VideoTab from './components/VideoTab.vue'
|
||||||
|
import ExtractTab from './components/ExtractTab.vue'
|
||||||
|
import LoginDialog from './components/LoginDialog.vue'
|
||||||
|
|
||||||
|
const currentTab = ref('video')
|
||||||
|
const showLogin = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(to bottom, #fefefe, #ededef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 70px;
|
||||||
|
background: #7163ba;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-right: 2px solid #ebedf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.active {
|
||||||
|
background: #8b4513;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
margin: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
334
wails3-app/frontend/src/components/ExtractTab.vue
Normal file
334
wails3-app/frontend/src/components/ExtractTab.vue
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
<template>
|
||||||
|
<div class="extract-tab">
|
||||||
|
<h2>视频抽帧</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择文件夹:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="folderPath"
|
||||||
|
placeholder="请选择包含视频文件的目录"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button @click="selectFolder">选择文件夹</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" v-if="videos.length > 0">
|
||||||
|
<label>视频文件:</label>
|
||||||
|
<div class="video-list">
|
||||||
|
<div v-for="(video, index) in videos" :key="index" class="video-item">
|
||||||
|
{{ getFileName(video) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">共 {{ videos.length }} 个视频文件</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>每个视频生成数量:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="extractCount"
|
||||||
|
:min="1"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="startExtract"
|
||||||
|
:disabled="!canExtract || isProcessing"
|
||||||
|
>
|
||||||
|
{{ isProcessing ? '处理中...' : '开始抽帧' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="startModify"
|
||||||
|
:disabled="!canModify || isProcessing"
|
||||||
|
style="margin-left: 10px;"
|
||||||
|
>
|
||||||
|
{{ isProcessing ? '处理中...' : '开始修改元数据' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="helpInfo" class="help-info">
|
||||||
|
{{ helpInfo }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length > 0" class="results">
|
||||||
|
<h3>处理结果</h3>
|
||||||
|
<div class="result-summary">
|
||||||
|
<p>成功: {{ successCount }} 个</p>
|
||||||
|
<p>失败: {{ failCount }} 个</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
let extractService = null
|
||||||
|
let fileService = null
|
||||||
|
|
||||||
|
if (window.go && window.go.services) {
|
||||||
|
extractService = window.go.services.ExtractService
|
||||||
|
fileService = window.go.services.FileService
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = ref('')
|
||||||
|
const videos = ref([])
|
||||||
|
const extractCount = ref(1)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const helpInfo = ref('')
|
||||||
|
const results = ref([])
|
||||||
|
|
||||||
|
const canExtract = computed(() => {
|
||||||
|
return folderPath.value && videos.value.length > 0 && extractCount.value > 0 && !isProcessing.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canModify = computed(() => {
|
||||||
|
return folderPath.value && videos.value.length > 0 && !isProcessing.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const successCount = computed(() => {
|
||||||
|
return results.value.filter(r => r.success).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const failCount = computed(() => {
|
||||||
|
return results.value.filter(r => !r.success).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectFolder = async () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.webkitdirectory = true
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const files = Array.from(e.target.files).filter(f => f.name.endsWith('.mp4'))
|
||||||
|
if (files.length > 0) {
|
||||||
|
const firstFile = files[0]
|
||||||
|
const path = firstFile.webkitRelativePath.split('/')[0]
|
||||||
|
folderPath.value = path
|
||||||
|
videos.value = files.map(f => f.name)
|
||||||
|
|
||||||
|
// 调用后端列出视频
|
||||||
|
if (extractService && extractService.ListVideos) {
|
||||||
|
try {
|
||||||
|
videos.value = await extractService.ListVideos(folderPath.value)
|
||||||
|
} catch (error) {
|
||||||
|
alert('列出视频失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileName = (path) => {
|
||||||
|
return path.split('/').pop() || path.split('\\').pop() || path
|
||||||
|
}
|
||||||
|
|
||||||
|
const startExtract = async () => {
|
||||||
|
if (!canExtract.value) return
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
results.value = []
|
||||||
|
helpInfo.value = `开始处理,每个视频将生成 ${extractCount.value} 个抽帧视频...`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
folderPath: folderPath.value,
|
||||||
|
extractCount: extractCount.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractService && extractService.ExtractFrames) {
|
||||||
|
const total = videos.value.length * extractCount.value
|
||||||
|
helpInfo.value = `处理中... (0/${total})`
|
||||||
|
|
||||||
|
const extractResults = await extractService.ExtractFrames(request)
|
||||||
|
if (extractResults) {
|
||||||
|
results.value = extractResults
|
||||||
|
helpInfo.value = `全部完成! 共处理 ${total} 个任务,成功 ${successCount.value} 个,失败 ${failCount.value} 个`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('抽帧失败: ' + error.message)
|
||||||
|
helpInfo.value = '处理失败: ' + error.message
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startModify = async () => {
|
||||||
|
if (!canModify.value) return
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
results.value = []
|
||||||
|
helpInfo.value = '开始修改元数据...'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (extractService && extractService.ModifyVideosMetadata) {
|
||||||
|
const total = videos.value.length
|
||||||
|
helpInfo.value = `处理中... (0/${total})`
|
||||||
|
|
||||||
|
const modifyResults = await extractService.ModifyVideosMetadata(folderPath.value)
|
||||||
|
if (modifyResults) {
|
||||||
|
results.value = modifyResults
|
||||||
|
helpInfo.value = `全部完成! 共处理 ${total} 个任务,成功 ${successCount.value} 个,失败 ${failCount.value} 个`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('修改失败: ' + error.message)
|
||||||
|
helpInfo.value = '处理失败: ' + error.message
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.extract-tab {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button:hover {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-list {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
padding: 15px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary p {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
200
wails3-app/frontend/src/components/LoginDialog.vue
Normal file
200
wails3-app/frontend/src/components/LoginDialog.vue
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dialog-overlay" @click="$emit('close')">
|
||||||
|
<div class="dialog" @click.stop>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3>用户登录</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名:</label>
|
||||||
|
<input type="text" v-model="username" placeholder="请输入用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码:</label>
|
||||||
|
<input type="password" v-model="password" placeholder="请输入密码" />
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="error-message">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button class="btn-secondary" @click="$emit('close')">取消</button>
|
||||||
|
<button class="btn-primary" @click="handleLogin" :disabled="isLogging">
|
||||||
|
{{ isLogging ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
let authService = null
|
||||||
|
|
||||||
|
if (window.go && window.go.services) {
|
||||||
|
authService = window.go.services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const isLogging = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!username.value || !password.value) {
|
||||||
|
errorMessage.value = '请输入用户名和密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLogging.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (authService && authService.Login) {
|
||||||
|
const response = await authService.Login(username.value, password.value)
|
||||||
|
if (response.Code === 200) {
|
||||||
|
alert('登录成功!')
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
errorMessage.value = response.Msg || '登录失败'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage.value = '认证服务未初始化'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = '登录失败: ' + error.message
|
||||||
|
} finally {
|
||||||
|
isLogging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #7163ba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
403
wails3-app/frontend/src/components/VideoTab.vue
Normal file
403
wails3-app/frontend/src/components/VideoTab.vue
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<div class="video-tab">
|
||||||
|
<h2>视频拼接</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择文件夹:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="folderPath"
|
||||||
|
placeholder="请选择包含视频文件夹的目录"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button @click="selectFolder">选择文件夹</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" v-if="folderInfos.length > 0">
|
||||||
|
<label>文件夹列表:</label>
|
||||||
|
<div class="folder-list">
|
||||||
|
<div v-for="(folder, index) in folderInfos" :key="index" class="folder-item">
|
||||||
|
<span>{{ folder.name }}</span>
|
||||||
|
<span class="video-count">{{ folder.videoCount }} 个视频</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>拼接模式:</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" v-model="joinType" :value="1" />
|
||||||
|
组合拼接(从每个文件夹随机选择)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" v-model="joinType" :value="2" />
|
||||||
|
顺序拼接(按索引顺序)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>生成数量:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="num"
|
||||||
|
:min="1"
|
||||||
|
:max="maxNum"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
/>
|
||||||
|
<span class="hint">最大可生成:{{ maxNum }} 个</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>审核图片(可选):</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="auditImagePath"
|
||||||
|
placeholder="选择审核图片"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button @click="selectAuditImage">选择图片</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
@click="startConcat"
|
||||||
|
:disabled="!canStart || isProcessing"
|
||||||
|
>
|
||||||
|
{{ isProcessing ? '处理中...' : '开始拼接' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="concatResults.length > 0" class="results">
|
||||||
|
<h3>拼接结果</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>序号</th>
|
||||||
|
<th>文件名</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>时长</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>进度</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="result in concatResults" :key="result.index">
|
||||||
|
<td>{{ result.index }}</td>
|
||||||
|
<td>{{ result.fileName }}</td>
|
||||||
|
<td>{{ result.size }}</td>
|
||||||
|
<td>{{ result.seconds }}秒</td>
|
||||||
|
<td :class="result.status === '拼接成功' ? 'success' : 'error'">
|
||||||
|
{{ result.status }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: result.progress }"
|
||||||
|
></div>
|
||||||
|
<span class="progress-text">{{ result.progress }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
// 使用 Wails3 的绑定
|
||||||
|
let videoService = null
|
||||||
|
let fileService = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取绑定的服务(需要根据 Wails3 的实际 API 调整)
|
||||||
|
if (window.go && window.go.services) {
|
||||||
|
videoService = window.go.services.VideoService
|
||||||
|
fileService = window.go.services.FileService
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderPath = ref('')
|
||||||
|
const folderInfos = ref([])
|
||||||
|
const joinType = ref(1)
|
||||||
|
const num = ref(1)
|
||||||
|
const maxNum = ref(0)
|
||||||
|
const auditImagePath = ref('')
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const concatResults = ref([])
|
||||||
|
|
||||||
|
const canStart = computed(() => {
|
||||||
|
return folderPath.value && num.value > 0 && num.value <= maxNum.value && !isProcessing.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectFolder = async () => {
|
||||||
|
// 使用系统对话框选择文件夹
|
||||||
|
// 注意:在浏览器环境中需要使用 input[type=file] 的 webkitdirectory 属性
|
||||||
|
// 或者通过后端调用系统 API
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.webkitdirectory = true
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
// 获取第一个文件的目录路径
|
||||||
|
const firstFile = files[0]
|
||||||
|
const path = firstFile.webkitRelativePath.split('/')[0]
|
||||||
|
folderPath.value = path
|
||||||
|
|
||||||
|
// 调用后端列出文件夹
|
||||||
|
if (videoService && videoService.ListFolders) {
|
||||||
|
try {
|
||||||
|
folderInfos.value = await videoService.ListFolders(folderPath.value)
|
||||||
|
updateMaxNum()
|
||||||
|
} catch (error) {
|
||||||
|
alert('列出文件夹失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAuditImage = () => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
auditImagePath.value = file.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMaxNum = () => {
|
||||||
|
if (joinType.value === 1) {
|
||||||
|
// 组合拼接:所有文件夹视频数量的乘积
|
||||||
|
maxNum.value = folderInfos.value.reduce((acc, folder) => acc * folder.videoCount, 1)
|
||||||
|
} else {
|
||||||
|
// 顺序拼接:第一个文件夹的视频数量
|
||||||
|
maxNum.value = folderInfos.value.length > 0 ? folderInfos.value[0].videoCount : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startConcat = async () => {
|
||||||
|
if (!canStart.value) return
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
concatResults.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
folderPath: folderPath.value,
|
||||||
|
num: num.value,
|
||||||
|
joinType: joinType.value,
|
||||||
|
auditImagePath: auditImagePath.value,
|
||||||
|
folderInfos: folderInfos.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用后端拼接视频
|
||||||
|
if (videoService && videoService.JoinVideos) {
|
||||||
|
const results = await videoService.JoinVideos(request)
|
||||||
|
if (results) {
|
||||||
|
concatResults.value = results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('拼接失败: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-tab {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button:hover {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #5a5080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-list {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-count {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: #7163ba;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 20px;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #28a745;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
5
wails3-app/frontend/src/main.js
Normal file
5
wails3-app/frontend/src/main.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
|
|
||||||
12
wails3-app/frontend/vite.config.js
Normal file
12
wails3-app/frontend/vite.config.js
Normal file
@ -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: './',
|
||||||
|
})
|
||||||
|
|
||||||
12
wails3-app/go.mod
Normal file
12
wails3-app/go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
104
wails3-app/services/auth_service.go
Normal file
104
wails3-app/services/auth_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
318
wails3-app/services/extract_service.go
Normal file
318
wails3-app/services/extract_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
76
wails3-app/services/file_service.go
Normal file
76
wails3-app/services/file_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
60
wails3-app/services/log.go
Normal file
60
wails3-app/services/log.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
413
wails3-app/services/video_service.go
Normal file
413
wails3-app/services/video_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
23
wails3-app/wails.json
Normal file
23
wails3-app/wails.json
Normal file
@ -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": "视频拼接工具"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
100
wails3-app/快速开始.md
Normal file
100
wails3-app/快速开始.md
Normal file
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
151
wails3-app/构建说明.md
Normal file
151
wails3-app/构建说明.md
Normal file
@ -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 官方文档的打包说明。
|
||||||
|
|
||||||
166
wails3-app/迁移说明.md
Normal file
166
wails3-app/迁移说明.md
Normal file
@ -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. 性能优化
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user