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