fix: 添加导入素材

This commit is contained in:
root 2025-07-11 16:00:46 +08:00
parent 0b322d06fb
commit c51ede77f7
3 changed files with 668 additions and 3 deletions

View File

@ -0,0 +1,231 @@
# 项目素材导入功能使用指南
## 🎯 功能概述
项目素材导入功能允许用户为特定项目批量导入视频素材,支持选择模特关联、自动标签和自动分镜功能,大大提升素材管理效率。
## 🚀 功能特点
### 1. 灵活的文件选择
- **单文件/多文件选择**: 支持选择一个或多个视频文件
- **文件夹批量导入**: 选择整个文件夹进行批量导入
- **格式支持**: 支持 MP4、AVI、MOV、MKV、WMV、FLV、WebM、M4V 等主流视频格式
### 2. 智能关联功能
- **项目自动关联**: 导入的素材自动关联到当前项目
- **模特标签关联**: 可选择关联的模特,自动添加模特标签
- **项目标签**: 自动添加项目商品名作为标签
### 3. 自动分镜功能
- **智能分镜**: 自动将长视频分割成多个片段
- **场景识别**: 基于场景变化进行分镜
- **便于编辑**: 分镜后的片段更适合后续编辑使用
### 4. 标签管理
- **自定义标签**: 可添加自定义标签
- **自动标签**: 系统自动添加项目和模特相关标签
- **标签预览**: 实时预览将要添加的标签
## 📍 使用位置
在项目详情页面的素材管理区域,点击右上角的"导入素材"按钮即可打开导入功能。
## 🔧 使用步骤
### 步骤1: 打开导入功能
1. 进入项目详情页面
2. 在素材管理区域找到"导入素材"按钮
3. 点击按钮打开导入模态框
### 步骤2: 选择素材文件
有两种选择方式:
#### 方式A: 选择单个或多个文件
1. 点击"选择文件"按钮
2. 在文件选择对话框中选择视频文件
3. 支持按住Ctrl/Cmd键多选文件
#### 方式B: 选择整个文件夹
1. 点击"选择文件夹"按钮
2. 选择包含视频文件的文件夹
3. 系统会自动扫描文件夹中的所有支持格式的视频文件
### 步骤3: 配置导入选项
#### 关联模特(可选)
- 勾选要关联的模特
- 选中的模特标签会自动添加到导入的素材中
- 有助于后续按模特筛选素材
#### 添加标签(可选)
- 在标签输入框中输入自定义标签
- 按回车键或点击"+"按钮添加标签
- 可添加多个标签,点击标签上的删除按钮可移除
#### 自动分镜设置
- 默认启用自动分镜功能
- 取消勾选可禁用自动分镜
- 启用后长视频会被自动分割成多个片段
### 步骤4: 开始导入
1. 确认所有配置无误
2. 点击"开始导入"按钮
3. 等待导入完成
## 📊 导入过程
### 1. 文件验证
- 检查文件格式是否支持
- 验证文件完整性
- 过滤不支持的文件
### 2. 素材处理
- 提取视频元数据(时长、分辨率、帧率等)
- 生成MD5哈希值防重复
- 创建缩略图
### 3. 自动分镜(如果启用)
- 分析视频场景变化
- 智能确定分镜点
- 生成多个视频片段
### 4. 标签处理
- 添加项目相关标签
- 添加模特相关标签
- 添加用户自定义标签
### 5. 数据存储
- 保存素材信息到数据库
- 建立项目关联关系
- 更新素材统计
## 📋 导入结果
导入完成后会显示详细的结果信息:
### 成功信息
- **总文件数**: 尝试导入的文件总数
- **成功导入**: 成功导入的文件数量
- **生成片段**: 自动分镜生成的片段数量
- **跳过文件**: 因重复或其他原因跳过的文件
- **失败文件**: 导入失败的文件数量
### 错误信息
如果有文件导入失败,会显示具体的错误信息,包括:
- 文件名
- 失败原因
- 建议解决方案
## 🎯 最佳实践
### 1. 文件组织
- **统一命名**: 使用有意义的文件名
- **分类存放**: 按类型或用途组织文件夹
- **避免特殊字符**: 文件名避免使用特殊字符
### 2. 标签策略
- **描述性标签**: 使用描述性的标签名称
- **统一规范**: 团队内部统一标签命名规范
- **分层标签**: 使用分层的标签体系
**推荐标签示例**:
```
类型标签: 产品展示、人物介绍、场景拍摄
风格标签: 商务、休闲、时尚、科技
用途标签: 主视频、背景素材、转场素材
```
### 3. 模特关联
- **准确关联**: 确保选择正确的模特
- **完整关联**: 包含该模特的素材都应该关联
- **及时更新**: 新增模特后及时关联相关素材
### 4. 自动分镜
- **长视频启用**: 对于超过1分钟的视频建议启用
- **短视频禁用**: 对于短视频可以禁用以保持完整性
- **后期调整**: 可在导入后手动调整分镜结果
## 🔍 故障排除
### 问题1: 文件选择失败
**可能原因**:
- 文件路径包含特殊字符
- 文件被其他程序占用
- 权限不足
**解决方案**:
1. 检查文件路径,避免特殊字符
2. 关闭可能占用文件的程序
3. 确保有足够的文件访问权限
### 问题2: 导入失败
**可能原因**:
- 文件格式不支持
- 文件损坏
- 磁盘空间不足
- 网络连接问题
**解决方案**:
1. 确认文件格式在支持列表中
2. 检查文件完整性
3. 确保有足够的磁盘空间
4. 检查网络连接状态
### 问题3: 自动分镜效果不佳
**可能原因**:
- 视频场景变化不明显
- 视频质量较低
- 分镜参数不合适
**解决方案**:
1. 对于场景单一的视频可禁用自动分镜
2. 提高视频质量
3. 手动调整分镜结果
### 问题4: 标签丢失
**可能原因**:
- 导入过程中断
- 标签格式错误
- 系统异常
**解决方案**:
1. 重新导入素材
2. 检查标签格式
3. 手动添加丢失的标签
## 🚀 高级功能
### 批量操作
- 支持同时导入多个文件夹
- 支持批量设置标签和模特关联
- 支持批量启用/禁用自动分镜
### 导入模板
- 保存常用的导入配置
- 快速应用预设配置
- 团队共享导入模板
### 进度监控
- 实时显示导入进度
- 支持暂停和恢复导入
- 详细的处理日志
## 📈 性能优化
### 导入速度优化
- **小批量导入**: 避免一次导入过多文件
- **网络稳定**: 确保网络连接稳定
- **系统资源**: 确保系统有足够的内存和CPU资源
### 存储优化
- **定期清理**: 定期清理不需要的素材
- **压缩设置**: 合理设置视频压缩参数
- **分布存储**: 大量素材可考虑分布式存储
---
通过项目素材导入功能,您可以高效地管理项目素材,提升视频制作效率!

View File

@ -0,0 +1,400 @@
/**
*
*
*/
import React, { useState } from 'react'
import { X, Upload, FolderOpen, Users, Scissors, Video, CheckCircle, Plus, Trash2 } from 'lucide-react'
import { open } from '@tauri-apps/plugin-dialog'
import { Project } from '../services/projectService'
import { Model } from '../services/modelService'
import { MediaService, BatchUploadResult } from '../services/mediaService'
interface ImportMaterialModalProps {
project: Project
models: Model[]
onConfirm: (result: BatchUploadResult) => void
onCancel: () => void
}
const ImportMaterialModal: React.FC<ImportMaterialModalProps> = ({
project,
models,
onConfirm,
onCancel
}) => {
const [uploading, setUploading] = useState(false)
const [uploadResult, setUploadResult] = useState<BatchUploadResult | null>(null)
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
const [selectedDirectory, setSelectedDirectory] = useState<string>('')
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
const [enableAutoSegmentation, setEnableAutoSegmentation] = useState(true)
// 选择文件
const selectFiles = async () => {
try {
const files = await open({
multiple: true,
title: '选择视频文件',
filters: [
{
name: '视频文件',
extensions: ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v']
}
]
})
if (files && Array.isArray(files)) {
setSelectedFiles(files)
setSelectedDirectory('')
}
} catch (error) {
console.error('Failed to select files:', error)
}
}
// 选择目录
const selectDirectory = async () => {
try {
const directory = await open({
directory: true,
multiple: false,
title: '选择视频文件夹'
})
if (directory && typeof directory === 'string') {
setSelectedDirectory(directory)
setSelectedFiles([])
}
} catch (error) {
console.error('Failed to select directory:', error)
}
}
// 添加标签
const addTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags([...tags, newTag.trim()])
setNewTag('')
}
}
// 移除标签
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove))
}
// 处理导入
const handleImport = async () => {
if (selectedFiles.length === 0 && !selectedDirectory) {
alert('请先选择文件或文件夹')
return
}
setUploading(true)
setUploadResult(null)
try {
// 准备导入参数
const importTags = [...tags]
// 添加项目标签
if (project.product_name && !importTags.includes(project.product_name)) {
importTags.push(project.product_name)
}
// 添加模特标签
const selectedModels = models.filter(m => selectedModelIds.includes(m.id))
selectedModels.forEach(model => {
if (!importTags.includes(model.model_number)) {
importTags.push(model.model_number)
}
})
let result: BatchUploadResult
if (selectedDirectory) {
// 批量导入文件夹
const response = await MediaService.batchUploadVideoFiles({
source_directory: selectedDirectory,
tags: importTags.length > 0 ? importTags : undefined
})
if (response.status && response.data) {
result = response.data
} else {
throw new Error(response.msg || '批量导入失败')
}
} else {
// 导入单个或多个文件
result = {
total_files: selectedFiles.length,
uploaded_files: 0,
failed_files: 0,
skipped_files: 0,
total_segments: 0,
uploaded_list: [],
skipped_list: [],
failed_list: []
}
for (const filePath of selectedFiles) {
try {
const response = await MediaService.uploadVideoFile({
source_path: filePath,
tags: importTags
})
if (response.status && response.data) {
result.uploaded_files++
result.total_segments += response.data.segments.length
result.uploaded_list.push(response.data)
} else {
result.failed_files++
result.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: response.msg || '上传失败'
})
}
} catch (error) {
result.failed_files++
result.failed_list.push({
filename: filePath.split('/').pop() || filePath,
error: String(error)
})
}
}
}
setUploadResult(result)
// 如果有成功的上传,调用完成回调
if (result.uploaded_files > 0) {
onConfirm(result)
}
} catch (error) {
console.error('Import failed:', error)
alert('导入失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setUploading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* 标题栏 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-600"> "{project.name}" </p>
</div>
<button
onClick={onCancel}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
</div>
<div className="p-6 space-y-6">
{/* 文件选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={selectFiles}
disabled={uploading}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors disabled:opacity-50"
>
<Video className="w-8 h-8 text-gray-400 mb-2" />
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-xs text-gray-500"></span>
</button>
<button
onClick={selectDirectory}
disabled={uploading}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors disabled:opacity-50"
>
<FolderOpen className="w-8 h-8 text-gray-400 mb-2" />
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-xs text-gray-500"></span>
</button>
</div>
{/* 显示选择的文件或目录 */}
{selectedFiles.length > 0 && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center text-blue-700">
<CheckCircle className="w-4 h-4 mr-2" />
<span className="text-sm font-medium"> {selectedFiles.length} </span>
</div>
</div>
)}
{selectedDirectory && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center text-blue-700">
<CheckCircle className="w-4 h-4 mr-2" />
<span className="text-sm font-medium"></span>
</div>
<div className="text-xs text-blue-600 mt-1 truncate">{selectedDirectory}</div>
</div>
)}
</div>
{/* 模特选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
<Users className="w-4 h-4 inline mr-1" />
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-h-32 overflow-y-auto">
{models.map((model) => (
<label key={model.id} className="flex items-center">
<input
type="checkbox"
checked={selectedModelIds.includes(model.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedModelIds([...selectedModelIds, model.id])
} else {
setSelectedModelIds(selectedModelIds.filter(id => id !== model.id))
}
}}
disabled={uploading}
className="mr-2"
/>
<span className="text-sm text-gray-700">{model.model_number}</span>
</label>
))}
</div>
</div>
{/* 标签设置 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex items-center space-x-2 mb-3">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTag()}
placeholder="输入标签名称"
disabled={uploading}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={addTag}
disabled={!newTag.trim() || uploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Plus size={16} />
</button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
{tag}
<button
onClick={() => removeTag(tag)}
disabled={uploading}
className="ml-2 text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
<Trash2 size={12} />
</button>
</span>
))}
</div>
)}
</div>
{/* 自动分镜设置 */}
<div>
<label className="flex items-center mb-3">
<input
type="checkbox"
checked={enableAutoSegmentation}
onChange={(e) => setEnableAutoSegmentation(e.target.checked)}
disabled={uploading}
className="mr-2"
/>
<Scissors className="w-4 h-4 mr-1" />
<span className="text-sm font-medium text-gray-700"></span>
</label>
<p className="text-xs text-gray-500 ml-6">
便使
</p>
</div>
{/* 上传结果 */}
{uploadResult && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center text-green-700 mb-2">
<CheckCircle className="w-5 h-5 mr-2" />
<span className="font-medium"></span>
</div>
<div className="text-sm text-green-600 space-y-1">
<div>: {uploadResult.total_files}</div>
<div>: {uploadResult.uploaded_files}</div>
<div>: {uploadResult.failed_files}</div>
<div>: {uploadResult.skipped_files}</div>
<div>: {uploadResult.total_segments}</div>
</div>
{uploadResult.failed_list.length > 0 && (
<div className="mt-2">
<div className="text-sm font-medium text-red-700">:</div>
<div className="text-xs text-red-600 max-h-20 overflow-y-auto">
{uploadResult.failed_list.map((error, index) => (
<div key={index}> {error.filename}: {error.error}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-between p-6 border-t border-gray-200">
<button
onClick={onCancel}
disabled={uploading}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
{uploadResult ? '关闭' : '取消'}
</button>
<button
onClick={handleImport}
disabled={uploading || (selectedFiles.length === 0 && !selectedDirectory)}
className="flex items-center px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</button>
</div>
</div>
</div>
)
}
export default ImportMaterialModal

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react'
import { Video, Play, Search, Filter, Tag, Clock, HardDrive, Eye, User, Folder } from 'lucide-react'
import { Video, Play, Search, Filter, Tag, Clock, HardDrive, Eye, User, Folder, Upload } from 'lucide-react'
import { Project } from '../services/projectService'
import { VideoSegment, MediaService } from '../services/mediaService'
import { Model } from '../services/modelService'
import { ResourceCategory, ResourceCategoryService } from '../services/resourceCategoryService'
import { BatchUploadResult } from '../services/mediaService'
import VideoPlayer from './VideoPlayer'
import ImportMaterialModal from './ImportMaterialModal'
interface ProjectMaterialsCenterProps {
project: Project
@ -25,6 +27,7 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
const [usageFilter, setUsageFilter] = useState<'all' | 'used' | 'unused'>('all')
const [selectedMaterial, setSelectedMaterial] = useState<VideoSegment | null>(null)
const [showVideoPlayer, setShowVideoPlayer] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
// 素材分类相关状态
const [categories, setCategories] = useState<ResourceCategory[]>([])
@ -153,6 +156,18 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
setShowVideoPlayer(true)
}
// 处理导入完成
const handleImportComplete = (result: BatchUploadResult) => {
setShowImportModal(false)
// 刷新素材列表
onMaterialsChange([...materials])
// 显示导入结果
const message = `导入完成!\n成功: ${result.uploaded_files} 个文件\n生成片段: ${result.total_segments}`
alert(message)
}
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
@ -186,9 +201,18 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<div className="flex items-center space-x-3">
<div className="text-sm text-gray-600">
{filteredMaterials.length} / {materials.length}
</div>
<button
onClick={() => setShowImportModal(true)}
className="flex items-center px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload size={14} className="mr-1" />
</button>
</div>
</div>
{/* 筛选器 - 紧凑布局 */}
@ -468,6 +492,16 @@ const ProjectMaterialsCenter: React.FC<ProjectMaterialsCenterProps> = ({
}`}
/>
)}
{/* 导入素材模态框 */}
{showImportModal && (
<ImportMaterialModal
project={project}
models={models}
onConfirm={handleImportComplete}
onCancel={() => setShowImportModal(false)}
/>
)}
</div>
)
}