This commit is contained in:
xiangbing 2026-01-06 19:35:21 +08:00
parent 94b83ce401
commit 7a13a002ba
21 changed files with 2740 additions and 0 deletions

30
wails3-app/.gitignore vendored Normal file
View 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
View 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
View 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)
}
}

View 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>

View 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"
}
}

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View 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
View 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
)

View 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
}

View 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
}

View 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
}

View 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))
}

View 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
View 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
View 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
View 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
View 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. 性能优化