This commit is contained in:
root
2025-07-12 16:12:06 +08:00
parent 808c143bf8
commit 2b5867342f
22 changed files with 1785 additions and 2 deletions

View File

@@ -0,0 +1,292 @@
"""
项目素材管理CLI命令
"""
from pathlib import Path
from typing import Optional, List
import typer
from rich.console import Console
from rich.table import Table
from python_core.services.project_material_service import ProjectMaterialService
from python_core.scene_detection.single_scene_detector import SingleSceneDetector
from python_core.scene_detection.types.enums import DetectorType
console = Console()
material_app = typer.Typer(name="material", help="项目素材管理命令")
@material_app.command("import")
def import_video(
video_path: str = typer.Argument(..., help="视频文件路径"),
project_id: str = typer.Argument(..., help="项目ID"),
project_directory: str = typer.Argument(..., help="项目目录路径"),
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="素材标签,用逗号分隔"),
detector_type: str = typer.Option("content", "--detector", "-d", help="检测器类型"),
threshold: float = typer.Option(30.0, "--threshold", help="检测阈值"),
min_scene_length: float = typer.Option(1.0, "--min-length", help="最小场景长度(秒)"),
split_quality: int = typer.Option(23, "--quality", "-q", help="切分质量"),
split_preset: str = typer.Option("fast", "--preset", help="编码预设"),
max_duration: float = typer.Option(2.0, "--max-duration", "-m", help="最大视频时长限制(秒)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="详细输出")
):
"""导入单个视频到项目素材库"""
try:
console.print(f"📦 [bold blue]导入视频到项目素材库[/bold blue]")
console.print(f"📁 视频文件: {video_path}")
console.print(f"🎯 项目ID: {project_id}")
console.print(f"📂 项目目录: {project_directory}")
# 解析标签
material_tags = []
if tags:
material_tags = [tag.strip() for tag in tags.split(",") if tag.strip()]
console.print(f"🏷️ 素材标签: {', '.join(material_tags)}")
# 验证输入参数
try:
detector_type_enum = DetectorType(detector_type)
except ValueError as e:
console.print(f"[red]❌ 参数错误: {e}[/red]")
raise typer.Exit(1)
# 创建检测器
detector = SingleSceneDetector()
# 验证视频文件
validation_result = detector.validate_video(video_path)
if not validation_result["valid"]:
console.print(f"[red]❌ 视频文件验证失败: {validation_result['error']}[/red]")
raise typer.Exit(1)
# 验证项目目录
project_dir_path = Path(project_directory)
if not project_dir_path.exists():
console.print(f"[red]❌ 项目目录不存在: {project_directory}[/red]")
raise typer.Exit(1)
console.print(f"✅ 验证通过,开始导入...")
# 执行导入
result = detector.import_to_project(
video_path=video_path,
project_id=project_id,
project_directory=project_directory,
material_tags=material_tags,
detector_type=detector_type_enum,
threshold=threshold,
min_scene_length=min_scene_length,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_duration
)
# 显示结果
if result["success"]:
console.print(f"\n✅ [bold green]导入完成![/bold green]")
console.print(f"📊 导入统计:")
console.print(f" 视频文件: {Path(result['video_path']).name}")
console.print(f" 处理时间: {result['processing_time']:.1f}s")
console.print(f" 场景数量: {result['total_scenes']}")
console.print(f" 导入片段: {result['total_segments']}")
console.print(f" 项目目录: {result['output_dir']}")
# 显示详细结果
if result.get("split_results") and verbose:
table = Table(title="导入素材详情")
table.add_column("片段", style="cyan")
table.add_column("时长", style="yellow")
table.add_column("文件大小", style="blue")
table.add_column("状态", style="magenta")
for split_result in result["split_results"]:
status = "✅ 已导入" if split_result["success"] else f"{split_result.get('error', '失败')}"
file_size = f"{split_result['file_size']:,} B" if split_result['file_size'] > 0 else "0 B"
duration = f"{split_result['duration']:.2f}s"
table.add_row(
str(split_result['scene_index'] + 1),
duration,
file_size,
status
)
console.print(table)
else:
console.print(f"\n[red]❌ 导入失败: {result.get('error', '未知错误')}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]❌ 项目导入失败: {str(e)}[/red]")
raise typer.Exit(1)
@material_app.command("list")
def list_materials(
project_directory: str = typer.Argument(..., help="项目目录路径"),
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="按标签过滤,用逗号分隔"),
limit: int = typer.Option(20, "--limit", "-l", help="显示数量限制"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="详细输出")
):
"""列出项目素材"""
try:
console.print(f"📋 [bold blue]项目素材列表[/bold blue]")
console.print(f"📂 项目目录: {project_directory}")
# 验证项目目录
project_dir_path = Path(project_directory)
if not project_dir_path.exists():
console.print(f"[red]❌ 项目目录不存在: {project_directory}[/red]")
raise typer.Exit(1)
# 创建素材服务
material_service = ProjectMaterialService()
# 获取素材列表
materials = material_service.get_project_materials(project_dir_path)
if not materials:
console.print("📭 项目中没有素材")
return
# 按标签过滤
if tags:
filter_tags = [tag.strip() for tag in tags.split(",") if tag.strip()]
materials = [
material for material in materials
if any(tag in material.get('tags', []) for tag in filter_tags)
]
console.print(f"🏷️ 按标签过滤: {', '.join(filter_tags)}")
# 限制显示数量
if len(materials) > limit:
materials = materials[:limit]
console.print(f"📊 显示前 {limit} 个素材(共 {len(materials)} 个)")
# 创建表格
table = Table(title="项目素材列表")
table.add_column("ID", style="cyan", width=8)
table.add_column("原始文件名", style="green")
table.add_column("时长", style="yellow")
table.add_column("文件大小", style="blue")
table.add_column("标签", style="magenta")
table.add_column("使用次数", style="red")
if verbose:
table.add_column("创建时间", style="dim")
for material in materials:
file_size = f"{material.get('file_size', 0):,} B"
duration = f"{material.get('duration', 0):.2f}s"
tags_str = ", ".join(material.get('tags', []))
use_count = str(material.get('use_count', 0))
row = [
material.get('id', '')[:8],
material.get('original_filename', ''),
duration,
file_size,
tags_str,
use_count
]
if verbose:
created_at = material.get('created_at', '')[:19].replace('T', ' ')
row.append(created_at)
table.add_row(*row)
console.print(table)
except Exception as e:
console.print(f"\n[red]❌ 获取素材列表失败: {str(e)}[/red]")
raise typer.Exit(1)
@material_app.command("stats")
def show_stats(
project_directory: str = typer.Argument(..., help="项目目录路径")
):
"""显示项目素材统计信息"""
try:
console.print(f"📊 [bold blue]项目素材统计[/bold blue]")
console.print(f"📂 项目目录: {project_directory}")
# 验证项目目录
project_dir_path = Path(project_directory)
if not project_dir_path.exists():
console.print(f"[red]❌ 项目目录不存在: {project_directory}[/red]")
raise typer.Exit(1)
# 创建素材服务
material_service = ProjectMaterialService()
# 获取统计信息
stats = material_service.get_material_stats(project_dir_path)
console.print(f"\n📈 [bold green]统计信息[/bold green]")
console.print(f" 总素材数: {stats['total_count']}")
console.print(f" 总文件大小: {stats['total_size']:,} 字节")
console.print(f" 总时长: {stats['total_duration']:.2f}")
console.print(f" 已使用: {stats['used_count']}")
console.print(f" 未使用: {stats['unused_count']}")
# 标签统计
if stats['tag_stats']:
console.print(f"\n🏷️ [bold green]标签统计[/bold green]")
for tag, count in sorted(stats['tag_stats'].items(), key=lambda x: x[1], reverse=True):
console.print(f" {tag}: {count}")
except Exception as e:
console.print(f"\n[red]❌ 获取统计信息失败: {str(e)}[/red]")
raise typer.Exit(1)
@material_app.command("remove")
def remove_material(
project_directory: str = typer.Argument(..., help="项目目录路径"),
material_id: str = typer.Argument(..., help="素材ID"),
force: bool = typer.Option(False, "--force", "-f", help="强制删除,不询问确认")
):
"""删除项目素材"""
try:
console.print(f"🗑️ [bold red]删除项目素材[/bold red]")
console.print(f"📂 项目目录: {project_directory}")
console.print(f"🎯 素材ID: {material_id}")
# 验证项目目录
project_dir_path = Path(project_directory)
if not project_dir_path.exists():
console.print(f"[red]❌ 项目目录不存在: {project_directory}[/red]")
raise typer.Exit(1)
# 创建素材服务
material_service = ProjectMaterialService()
# 确认删除
if not force:
confirm = typer.confirm("确定要删除这个素材吗?此操作不可撤销。")
if not confirm:
console.print("❌ 操作已取消")
return
# 删除素材
success = material_service.remove_material(project_dir_path, material_id)
if success:
console.print(f"✅ [bold green]素材删除成功[/bold green]")
else:
console.print(f"[red]❌ 素材删除失败[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]❌ 删除素材失败: {str(e)}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
material_app()

View File

@@ -17,6 +17,7 @@ from python_core.scene_detection import (
DetectorType,
OutputFormat
)
from python_core.scene_detection.single_scene_detector import SingleSceneDetector
from python_core.utils.logger import logger
scene_detect = typer.Typer(help="场景检测工具 - 重构版")
@@ -231,5 +232,212 @@ def _display_batch_results_table(tasks):
console.print(table)
@scene_detect.command("single")
def single_detect_and_split(
video_path: str = typer.Argument(..., help="视频文件路径"),
output_dir: Optional[str] = typer.Option(None, "--output", "-o", help="输出目录"),
detector_type: str = typer.Option("content", "--detector", "-d", help="检测器类型 (content/threshold/adaptive)"),
threshold: float = typer.Option(30.0, "--threshold", "-t", help="检测阈值 (0-100)"),
min_scene_length: float = typer.Option(1.0, "--min-length", "-l", help="最小场景长度(秒)"),
output_format: str = typer.Option("json", "--format", "-f", help="输出格式 (json/csv/txt)"),
ai_analysis: bool = typer.Option(False, "--ai/--no-ai", help="启用/禁用AI分析"),
video_splitting: bool = typer.Option(True, "--split/--no-split", help="启用/禁用视频切分"),
use_advanced_split: bool = typer.Option(True, "--advanced/--traditional", help="使用高效批量切分/传统逐个切分"),
split_quality: int = typer.Option(23, "--quality", "-q", help="切分质量 (CRF值, 18-28)"),
split_preset: str = typer.Option("fast", "--preset", help="编码预设 (ultrafast/fast/medium/slow)"),
max_duration: float = typer.Option(2.0, "--max-duration", "-m", help="最大视频时长限制(秒),超过将二次切分"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="详细输出")
):
"""单个视频文件的场景检测和切分"""
try:
console.print(f"🎬 [bold blue]单个视频场景检测和切分[/bold blue]")
console.print(f"📁 视频文件: {video_path}")
# 验证输入参数
try:
detector_type_enum = DetectorType(detector_type)
output_format_enum = OutputFormat(output_format)
except ValueError as e:
console.print(f"[red]❌ 参数错误: {e}[/red]")
raise typer.Exit(1)
# 创建检测器
detector = SingleSceneDetector()
# 验证视频文件
validation_result = detector.validate_video(video_path)
if not validation_result["valid"]:
console.print(f"[red]❌ 视频文件验证失败: {validation_result['error']}[/red]")
raise typer.Exit(1)
console.print(f"✅ 视频文件验证通过")
console.print(f"📊 文件大小: {validation_result['file_size']:,} 字节")
# 执行处理
result = detector.detect_and_split(
video_path=video_path,
output_dir=output_dir,
detector_type=detector_type_enum,
threshold=threshold,
min_scene_length=min_scene_length,
output_format=output_format_enum,
enable_ai_analysis=ai_analysis,
enable_video_splitting=video_splitting,
use_advanced_split=use_advanced_split,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_duration
)
# 显示结果
if result["success"]:
console.print(f"\n✅ [bold green]处理完成![/bold green]")
console.print(f"📊 处理统计:")
console.print(f" 视频文件: {Path(result['video_path']).name}")
console.print(f" 输出目录: {result['output_dir']}")
console.print(f" 处理时间: {result['processing_time']:.1f}s")
console.print(f" 场景数量: {result['total_scenes']}")
console.print(f" 切分片段: {result['total_segments']}")
# 显示详细结果表格
if result.get("split_results") and verbose:
table = Table(title="切分结果详情")
table.add_column("片段", style="cyan")
table.add_column("输出文件", style="green")
table.add_column("时长", style="yellow")
table.add_column("文件大小", style="blue")
table.add_column("状态", style="magenta")
for split_result in result["split_results"]:
status = "✅ 成功" if split_result["success"] else f"{split_result.get('error', '失败')}"
file_size = f"{split_result['file_size']:,} B" if split_result['file_size'] > 0 else "0 B"
duration = f"{split_result['duration']:.2f}s"
filename = Path(split_result['output_path']).name
table.add_row(
str(split_result['scene_index'] + 1),
filename,
duration,
file_size,
status
)
console.print(table)
else:
console.print(f"\n[red]❌ 处理失败: {result.get('error', '未知错误')}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]❌ 单个视频处理失败: {str(e)}[/red]")
raise typer.Exit(1)
@scene_detect.command("import")
def import_to_project(
video_path: str = typer.Argument(..., help="视频文件路径"),
project_id: str = typer.Argument(..., help="项目ID"),
project_directory: str = typer.Argument(..., help="项目目录路径"),
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="素材标签,用逗号分隔"),
detector_type: str = typer.Option("content", "--detector", "-d", help="检测器类型"),
threshold: float = typer.Option(30.0, "--threshold", help="检测阈值"),
min_scene_length: float = typer.Option(1.0, "--min-length", help="最小场景长度(秒)"),
split_quality: int = typer.Option(23, "--quality", "-q", help="切分质量"),
split_preset: str = typer.Option("fast", "--preset", help="编码预设"),
max_duration: float = typer.Option(2.0, "--max-duration", "-m", help="最大视频时长限制(秒)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="详细输出")
):
"""导入单个视频到项目素材库"""
try:
console.print(f"📦 [bold blue]导入视频到项目素材库[/bold blue]")
console.print(f"📁 视频文件: {video_path}")
console.print(f"🎯 项目ID: {project_id}")
console.print(f"📂 项目目录: {project_directory}")
# 解析标签
material_tags = []
if tags:
material_tags = [tag.strip() for tag in tags.split(",") if tag.strip()]
console.print(f"🏷️ 素材标签: {', '.join(material_tags)}")
# 验证输入参数
try:
detector_type_enum = DetectorType(detector_type)
except ValueError as e:
console.print(f"[red]❌ 参数错误: {e}[/red]")
raise typer.Exit(1)
# 创建检测器
detector = SingleSceneDetector()
# 验证视频文件
validation_result = detector.validate_video(video_path)
if not validation_result["valid"]:
console.print(f"[red]❌ 视频文件验证失败: {validation_result['error']}[/red]")
raise typer.Exit(1)
# 验证项目目录
project_dir_path = Path(project_directory)
if not project_dir_path.exists():
console.print(f"[red]❌ 项目目录不存在: {project_directory}[/red]")
raise typer.Exit(1)
console.print(f"✅ 验证通过,开始导入...")
# 执行导入
result = detector.import_to_project(
video_path=video_path,
project_id=project_id,
project_directory=project_directory,
material_tags=material_tags,
detector_type=detector_type_enum,
threshold=threshold,
min_scene_length=min_scene_length,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_duration
)
# 显示结果
if result["success"]:
console.print(f"\n✅ [bold green]导入完成![/bold green]")
console.print(f"📊 导入统计:")
console.print(f" 视频文件: {Path(result['video_path']).name}")
console.print(f" 处理时间: {result['processing_time']:.1f}s")
console.print(f" 场景数量: {result['total_scenes']}")
console.print(f" 导入片段: {result['total_segments']}")
console.print(f" 项目目录: {result['output_dir']}")
# 显示详细结果
if result.get("split_results") and verbose:
table = Table(title="导入素材详情")
table.add_column("片段", style="cyan")
table.add_column("时长", style="yellow")
table.add_column("文件大小", style="blue")
table.add_column("状态", style="magenta")
for split_result in result["split_results"]:
status = "✅ 已导入" if split_result["success"] else f"{split_result.get('error', '失败')}"
file_size = f"{split_result['file_size']:,} B" if split_result['file_size'] > 0 else "0 B"
duration = f"{split_result['duration']:.2f}s"
table.add_row(
str(split_result['scene_index'] + 1),
duration,
file_size,
status
)
console.print(table)
else:
console.print(f"\n[red]❌ 导入失败: {result.get('error', '未知错误')}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]❌ 项目导入失败: {str(e)}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
scene_detect()

View File

View File

View File

@@ -0,0 +1,4 @@
# 创建项目
# 不需要工作流
# {config.projects_dir}/{project.name}

View File

@@ -0,0 +1,5 @@
# 删除项目及项目下的资源
# 不需要工作流
# {config.projects_dir}/{project.name}

View File

@@ -0,0 +1,8 @@
# 获取项目详情
# 不需要工作流
# 项目详情:{config.projects_dir}/{project.name}/project.json
# 素材:{config.projects_dir}/{project.name}/material.json
# 模板:{config.projects_dir}/{project.name}/template.json
# 成品:{config.projects_dir}/{project.name}/product.json

View File

@@ -0,0 +1,4 @@
# 控制剪映 导出项目
# 需要工作流

View File

@@ -0,0 +1,5 @@
# 导入素材
# 需要工作流
# 1. 导入文件
# 2. 导入文件夹

View File

@@ -0,0 +1,3 @@
# 获取所有项目
# 不需要工作流
# {config.projects_dir}/projects.json

View File

@@ -0,0 +1,2 @@
# 混剪
# 需要工作流

View File

View File

View File

View File

@@ -0,0 +1,3 @@
# 导入文件夹

View File

@@ -0,0 +1 @@
# 导入文件

View File

@@ -0,0 +1,212 @@
"""
单个文件场景检测器
提供单个视频文件的场景检测和切分功能
"""
from pathlib import Path
from typing import Dict, Any, Optional, List
from python_core.utils.logger import logger
from .workflows.single_workflow_manager import SingleWorkflowManager
from .types.enums import DetectorType, OutputFormat
class SingleSceneDetector:
"""单个文件场景检测器"""
def __init__(self):
self.workflow_manager = SingleWorkflowManager()
logger.info("SingleSceneDetector 初始化完成")
def detect_and_split(
self,
video_path: str | Path,
output_dir: Optional[str | Path] = None,
detector_type: DetectorType = DetectorType.CONTENT,
threshold: float = 30.0,
min_scene_length: float = 1.0,
output_format: OutputFormat = OutputFormat.JSON,
enable_ai_analysis: bool = False,
enable_video_splitting: bool = True,
use_advanced_split: bool = True,
split_quality: int = 23,
split_preset: str = "fast",
max_video_duration: float = 60.0,
request_id: Optional[str] = None
) -> Dict[str, Any]:
"""
单个视频文件的场景检测和切分
Args:
video_path: 视频文件路径
output_dir: 输出目录如果为None则使用视频文件同目录
detector_type: 检测器类型 (content/threshold/adaptive)
threshold: 检测阈值 (0-100)
min_scene_length: 最小场景长度(秒)
output_format: 输出格式 (json/csv/txt)
enable_ai_analysis: 是否启用AI分析
enable_video_splitting: 是否启用视频切分
use_advanced_split: 是否使用高级切分
split_quality: 切分质量 (CRF值, 18-28)
split_preset: 编码预设 (ultrafast/fast/medium/slow)
max_video_duration: 最大视频时长限制(秒)
request_id: 请求ID用于JSON-RPC进度报告
Returns:
Dict: 处理结果
"""
# 转换路径
video_path = Path(video_path)
output_dir = Path(output_dir) if output_dir else None
return self.workflow_manager.detect_and_split_single_video(
video_path=video_path,
output_dir=output_dir,
detector_type=detector_type,
threshold=threshold,
min_scene_length=min_scene_length,
output_format=output_format,
enable_ai_analysis=enable_ai_analysis,
enable_video_splitting=enable_video_splitting,
use_advanced_split=use_advanced_split,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_video_duration,
request_id=request_id
)
def import_to_project(
self,
video_path: str | Path,
project_id: str,
project_directory: str | Path,
material_tags: Optional[List[str]] = None,
detector_type: DetectorType = DetectorType.CONTENT,
threshold: float = 30.0,
min_scene_length: float = 1.0,
split_quality: int = 23,
split_preset: str = "fast",
max_video_duration: float = 2.0,
request_id: Optional[str] = None
) -> Dict[str, Any]:
"""
导入单个视频到项目素材库
Args:
video_path: 视频文件路径
project_id: 项目ID
project_directory: 项目目录
material_tags: 素材标签列表
detector_type: 检测器类型
threshold: 检测阈值
min_scene_length: 最小场景长度
split_quality: 切分质量
split_preset: 编码预设
max_video_duration: 最大视频时长限制
request_id: 请求ID
Returns:
Dict: 导入结果
"""
# 转换路径
video_path = Path(video_path)
project_directory = Path(project_directory)
return self.workflow_manager.detect_and_split_for_project(
video_path=video_path,
project_id=project_id,
project_directory=project_directory,
material_tags=material_tags,
detector_type=detector_type,
threshold=threshold,
min_scene_length=min_scene_length,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_video_duration,
request_id=request_id
)
def validate_video(self, video_path: str | Path) -> Dict[str, Any]:
"""
验证视频文件
Args:
video_path: 视频文件路径
Returns:
Dict: 验证结果
"""
video_path = Path(video_path)
result = {
"valid": False,
"path": str(video_path),
"exists": False,
"is_file": False,
"supported_format": False,
"file_size": 0,
"error": None
}
try:
# 检查文件是否存在
result["exists"] = video_path.exists()
if not result["exists"]:
result["error"] = "文件不存在"
return result
# 检查是否为文件
result["is_file"] = video_path.is_file()
if not result["is_file"]:
result["error"] = "路径不是文件"
return result
# 检查文件格式
supported_formats = self.workflow_manager.get_supported_formats()
result["supported_format"] = video_path.suffix.lower() in supported_formats
if not result["supported_format"]:
result["error"] = f"不支持的格式: {video_path.suffix}"
return result
# 获取文件大小
result["file_size"] = video_path.stat().st_size
# 验证通过
result["valid"] = True
except Exception as e:
result["error"] = str(e)
return result
def get_supported_formats(self) -> List[str]:
"""获取支持的视频格式"""
return self.workflow_manager.get_supported_formats()
def get_detector_types(self) -> List[str]:
"""获取支持的检测器类型"""
return [dt.value for dt in DetectorType]
def get_output_formats(self) -> List[str]:
"""获取支持的输出格式"""
return [of.value for of in OutputFormat]
def get_default_config(self) -> Dict[str, Any]:
"""获取默认配置"""
return {
"detector_type": DetectorType.CONTENT.value,
"threshold": 30.0,
"min_scene_length": 1.0,
"output_format": OutputFormat.JSON.value,
"enable_ai_analysis": False,
"enable_video_splitting": True,
"use_advanced_split": True,
"split_quality": 23,
"split_preset": "fast",
"max_video_duration": 60.0,
"supported_formats": self.get_supported_formats(),
"detector_types": self.get_detector_types(),
"output_formats": self.get_output_formats()
}

View File

@@ -0,0 +1,185 @@
"""
单个文件场景检测工作流状态
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Dict, Any
from datetime import datetime
from .models import DetectionResult
from python_core.utils.jsonrpc_enhanced import ProgressLevel
@dataclass
class SingleVideoTask:
"""单个视频处理任务"""
video_path: Path
output_dir: Optional[Path] = None
# 任务状态
status: str = "pending" # pending, processing, completed, failed
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
error: Optional[str] = None
# 处理结果
detection_result: Optional[DetectionResult] = None
split_results: List[Any] = field(default_factory=list)
@dataclass
class SingleSceneDetectionWorkflowState:
"""单个文件场景检测工作流状态"""
# 基础配置
video_path: Path
output_dir: Optional[Path] = None
# 检测配置
detector_type: str = "content"
threshold: float = 30.0
min_scene_length: float = 1.0
output_format: str = "json"
# 功能开关
enable_ai_analysis: bool = False
enable_video_splitting: bool = True
# 视频切分配置
use_advanced_split: bool = True
split_quality: int = 23
split_preset: str = "fast"
max_video_duration: float = 60.0 # 最大视频时长(秒)
# 项目配置(用于项目素材导入)
project_id: Optional[str] = None
project_directory: Optional[Path] = None
material_tags: List[str] = field(default_factory=list)
# 工作流状态
task: Optional[SingleVideoTask] = None
current_step: str = "initialized"
progress: float = 0.0
# JSON-RPC 支持
request_id: Optional[str] = None
enable_jsonrpc: bool = False
def __post_init__(self):
"""初始化后处理"""
if self.task is None:
self.task = SingleVideoTask(
video_path=self.video_path,
output_dir=self.output_dir
)
def update_progress(self, step: str, progress: float, message: str = ""):
"""更新进度"""
self.current_step = step
self.progress = progress
if self.enable_jsonrpc and self.request_id:
self.send_progress(step, message, ProgressLevel.INFO, {
"progress": progress,
"step": step
})
def send_progress(self, event_type: str, message: str, level: ProgressLevel, data: Dict[str, Any] = None):
"""发送进度消息JSON-RPC"""
if not self.enable_jsonrpc or not self.request_id:
return
try:
from python_core.utils.jsonrpc_enhanced import create_response_handler
handler = create_response_handler(self.request_id)
handler.progress(
step=event_type,
progress=-1, # 不确定进度时使用-1
message=message,
level=level,
data=data or {}
)
except Exception as e:
print(f"Failed to send progress: {e}")
def mark_task_started(self):
"""标记任务开始"""
if self.task:
self.task.status = "processing"
self.task.start_time = datetime.now()
def mark_task_completed(self, detection_result: DetectionResult, split_results: List[Any] = None):
"""标记任务完成"""
if self.task:
self.task.status = "completed"
self.task.end_time = datetime.now()
self.task.detection_result = detection_result
self.task.split_results = split_results or []
def mark_task_failed(self, error: str):
"""标记任务失败"""
if self.task:
self.task.status = "failed"
self.task.end_time = datetime.now()
self.task.error = error
@property
def is_completed(self) -> bool:
"""是否已完成"""
return self.task and self.task.status == "completed"
@property
def is_failed(self) -> bool:
"""是否失败"""
return self.task and self.task.status == "failed"
@property
def processing_time(self) -> Optional[float]:
"""处理时间(秒)"""
if not self.task or not self.task.start_time:
return None
end_time = self.task.end_time or datetime.now()
return (end_time - self.task.start_time).total_seconds()
@property
def total_scenes(self) -> int:
"""总场景数"""
if self.task and self.task.detection_result:
return self.task.detection_result.total_scenes
return 0
@property
def total_split_segments(self) -> int:
"""总切分片段数"""
if self.task and self.task.split_results:
return len(self.task.split_results)
return 0
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"video_path": str(self.video_path),
"output_dir": str(self.output_dir) if self.output_dir else None,
"detector_type": self.detector_type,
"threshold": self.threshold,
"min_scene_length": self.min_scene_length,
"output_format": self.output_format,
"enable_ai_analysis": self.enable_ai_analysis,
"enable_video_splitting": self.enable_video_splitting,
"use_advanced_split": self.use_advanced_split,
"split_quality": self.split_quality,
"split_preset": self.split_preset,
"max_video_duration": self.max_video_duration,
"project_id": self.project_id,
"project_directory": str(self.project_directory) if self.project_directory else None,
"material_tags": self.material_tags,
"current_step": self.current_step,
"progress": self.progress,
"task_status": self.task.status if self.task else "pending",
"processing_time": self.processing_time,
"total_scenes": self.total_scenes,
"total_split_segments": self.total_split_segments,
"request_id": self.request_id
}

View File

@@ -0,0 +1,238 @@
"""
单个文件场景检测工作流管理器
"""
from pathlib import Path
from typing import Dict, Any, Optional, List
from python_core.utils.logger import logger
from .single_workflow_nodes import SingleWorkflowNodes
from ..types.single_workflow_state import SingleSceneDetectionWorkflowState
from ..types.enums import DetectorType, OutputFormat
class SingleWorkflowManager:
"""单个文件场景检测工作流管理器"""
def __init__(self):
self.workflow_nodes = SingleWorkflowNodes()
def detect_and_split_single_video(
self,
video_path: Path,
output_dir: Optional[Path] = None,
detector_type: DetectorType = DetectorType.CONTENT,
threshold: float = 30.0,
min_scene_length: float = 1.0,
output_format: OutputFormat = OutputFormat.JSON,
enable_ai_analysis: bool = False,
enable_video_splitting: bool = True,
use_advanced_split: bool = True,
split_quality: int = 23,
split_preset: str = "fast",
max_video_duration: float = 60.0,
project_id: Optional[str] = None,
project_directory: Optional[Path] = None,
material_tags: Optional[List[str]] = None,
request_id: Optional[str] = None
) -> Dict[str, Any]:
"""
单个视频文件的场景检测和切分
Args:
video_path: 视频文件路径
output_dir: 输出目录
detector_type: 检测器类型
threshold: 检测阈值
min_scene_length: 最小场景长度
output_format: 输出格式
enable_ai_analysis: 是否启用AI分析
enable_video_splitting: 是否启用视频切分
use_advanced_split: 是否使用高级切分
split_quality: 切分质量
split_preset: 切分预设
max_video_duration: 最大视频时长
project_id: 项目ID用于项目素材导入
project_directory: 项目目录(用于项目素材导入)
material_tags: 素材标签
request_id: 请求ID用于JSON-RPC进度报告
Returns:
Dict: 处理结果
"""
# 验证输入
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
if not video_path.suffix.lower() in {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.m4v'}:
raise ValueError(f"不支持的视频格式: {video_path.suffix}")
# 设置默认输出目录
if output_dir is None:
output_dir = video_path.parent / f"{video_path.stem}_scenes"
# 确保输出目录存在
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"🚀 开始单个视频场景检测和切分")
logger.info(f"📁 视频文件: {video_path}")
logger.info(f"📂 输出目录: {output_dir}")
logger.info(f"🎯 检测器: {detector_type.value}, 阈值: {threshold}")
logger.info(f"✂️ 视频切分: {'启用' if enable_video_splitting else '禁用'}")
if project_id:
logger.info(f"📦 项目导入: {project_id}")
# 创建工作流状态
state = SingleSceneDetectionWorkflowState(
video_path=video_path,
output_dir=output_dir,
detector_type=detector_type.value,
threshold=threshold,
min_scene_length=min_scene_length,
output_format=output_format.value,
enable_ai_analysis=enable_ai_analysis,
enable_video_splitting=enable_video_splitting,
use_advanced_split=use_advanced_split,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_video_duration,
project_id=project_id,
project_directory=project_directory,
material_tags=material_tags or [],
request_id=request_id,
enable_jsonrpc=request_id is not None
)
try:
# 执行工作流
result = self.workflow_nodes.process_single_video(state)
# 构建返回结果
response = {
"success": result["success"],
"video_path": str(video_path),
"output_dir": str(output_dir),
"processing_time": result.get("processing_time", 0),
"total_scenes": result.get("total_scenes", 0),
"total_segments": result.get("total_segments", 0),
"state": state.to_dict()
}
if result["success"]:
# 转换检测结果为字典
detection_result_dict = None
if result.get("detection_result"):
from dataclasses import asdict
detection_result_dict = asdict(result["detection_result"])
response.update({
"detection_result": detection_result_dict,
"split_results": [
{
"scene_index": sr.scene_index,
"output_path": str(sr.output_path),
"start_time": sr.start_time,
"end_time": sr.end_time,
"duration": sr.duration,
"file_size": sr.file_size,
"success": sr.success,
"error": sr.error
}
for sr in result.get("split_results", [])
]
})
logger.info(f"✅ 单个视频处理成功!")
logger.info(f"📊 处理统计:")
logger.info(f" 视频文件: {video_path.name}")
logger.info(f" 处理时间: {result.get('processing_time', 0):.1f}s")
logger.info(f" 场景数量: {result.get('total_scenes', 0)}")
logger.info(f" 切分片段: {result.get('total_segments', 0)}")
else:
response["error"] = result.get("error", "未知错误")
logger.error(f"❌ 单个视频处理失败: {response['error']}")
return response
except Exception as e:
error_msg = f"工作流执行失败: {str(e)}"
logger.error(f"{error_msg}")
return {
"success": False,
"error": error_msg,
"video_path": str(video_path),
"output_dir": str(output_dir),
"processing_time": state.processing_time or 0,
"state": state.to_dict()
}
def detect_and_split_for_project(
self,
video_path: Path,
project_id: str,
project_directory: Path,
material_tags: Optional[List[str]] = None,
detector_type: DetectorType = DetectorType.CONTENT,
threshold: float = 30.0,
min_scene_length: float = 1.0,
split_quality: int = 23,
split_preset: str = "fast",
max_video_duration: float = 2.0,
request_id: Optional[str] = None
) -> Dict[str, Any]:
"""
为项目导入单个视频素材
Args:
video_path: 视频文件路径
project_id: 项目ID
project_directory: 项目目录
material_tags: 素材标签
其他参数: 检测和切分配置
Returns:
Dict: 处理结果
"""
# 创建临时输出目录
temp_output_dir = project_directory / "temp" / f"{video_path.stem}_processing"
return self.detect_and_split_single_video(
video_path=video_path,
output_dir=temp_output_dir,
detector_type=detector_type,
threshold=threshold,
min_scene_length=min_scene_length,
output_format=OutputFormat.JSON,
enable_ai_analysis=False,
enable_video_splitting=True,
use_advanced_split=True,
split_quality=split_quality,
split_preset=split_preset,
max_video_duration=max_video_duration,
project_id=project_id,
project_directory=project_directory,
material_tags=material_tags,
request_id=request_id
)
def get_supported_formats(self) -> List[str]:
"""获取支持的视频格式"""
return ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.m4v']
def validate_video_file(self, video_path: Path) -> bool:
"""验证视频文件"""
if not video_path.exists():
return False
if not video_path.is_file():
return False
if video_path.suffix.lower() not in self.get_supported_formats():
return False
return True

View File

@@ -0,0 +1,315 @@
"""
单个文件场景检测工作流节点
"""
import os
import json
import hashlib
from pathlib import Path
from typing import Dict, Any
from datetime import datetime
from python_core.utils.logger import logger
from python_core.utils.jsonrpc_enhanced import ProgressLevel
from ..services.detector_service import SceneDetectorService
from ..services.ai_analysis_service import AIAnalysisService
from ..utils.result_saver import ResultSaver
from ..types.single_workflow_state import SingleSceneDetectionWorkflowState, SingleVideoTask
from python_core.services.ffmpeg_slice_service_sync import FfmpegSliceService, SliceOptions, SliceSegment
from python_core.services.project_material_service import ProjectMaterialService
class SingleWorkflowNodes:
"""单个文件场景检测工作流节点"""
def __init__(self):
self.detector_service = SceneDetectorService()
self.ai_service = AIAnalysisService()
self.result_saver = ResultSaver()
self.splitter_service = FfmpegSliceService()
self.material_service = ProjectMaterialService()
def process_single_video(self, state: SingleSceneDetectionWorkflowState) -> Dict[str, Any]:
"""处理单个视频文件"""
try:
state.mark_task_started()
state.update_progress("started", 0.0, f"开始处理视频: {state.video_path.name}")
logger.info(f"🎬 开始处理单个视频: {state.video_path.name}")
# 1. 场景检测
state.update_progress("detecting", 20.0, "正在进行场景检测...")
# 转换检测器类型
from ..types.enums import DetectorType
detector_type_enum = DetectorType(state.detector_type)
detection_result = self.detector_service.detect_scenes(
video_path=state.video_path,
detector_type=detector_type_enum,
threshold=state.threshold,
min_scene_length=state.min_scene_length
)
logger.info(f"✅ 场景检测完成: 检测到 {detection_result.total_scenes} 个场景")
# 2. 保存检测结果
if state.output_dir:
state.update_progress("saving", 40.0, "保存检测结果...")
# 转换输出格式
from ..types.enums import OutputFormat
output_format_enum = OutputFormat(state.output_format)
# 创建结果文件路径
result_filename = f"{state.video_path.stem}_scenes.{output_format_enum.value}"
result_path = state.output_dir / result_filename
self.result_saver.save_results(
detection_result,
result_path,
output_format_enum
)
# 3. AI分析如果启用
if state.enable_ai_analysis:
state.update_progress("ai_analysis", 60.0, "正在进行AI分析...")
try:
ai_result = self.ai_service.analyze_scenes(detection_result)
detection_result.ai_analysis = ai_result
logger.info("✅ AI分析完成")
except Exception as e:
logger.warning(f"⚠️ AI分析失败: {e}")
# 4. 视频切分(如果启用)
split_results = []
if state.enable_video_splitting and state.output_dir:
state.update_progress("splitting", 70.0, "正在进行视频切分...")
split_results = self._handle_video_splitting(state, detection_result)
# 5. 项目素材管理(如果是项目导入)
if state.project_id and state.project_directory:
state.update_progress("project_import", 90.0, "导入到项目素材库...")
self._handle_project_material_import(state, detection_result, split_results)
# 标记完成
state.mark_task_completed(detection_result, split_results)
state.update_progress("completed", 100.0, "处理完成")
logger.info(f"🎉 视频处理完成: {state.video_path.name}")
return {
"success": True,
"detection_result": detection_result,
"split_results": split_results,
"processing_time": state.processing_time,
"total_scenes": state.total_scenes,
"total_segments": state.total_split_segments
}
except Exception as e:
error_msg = f"处理视频失败: {str(e)}"
logger.error(f"{error_msg}")
state.mark_task_failed(error_msg)
state.update_progress("failed", 0.0, error_msg)
return {
"success": False,
"error": error_msg,
"processing_time": state.processing_time
}
def _handle_video_splitting(self, state: SingleSceneDetectionWorkflowState, detection_result) -> list:
"""处理视频切分"""
try:
# 创建切分选项
slice_options = SliceOptions(
crf=state.split_quality,
preset=state.split_preset
)
# 转换场景为SliceSegment
segments = [
SliceSegment(start=scene.start_time, end=scene.end_time)
for scene in detection_result.scenes
]
# 创建输出目录
scenes_dir = state.output_dir / "scenes"
scenes_dir.mkdir(parents=True, exist_ok=True)
# 检查是否无场景,如果是则跳过切分
if len(segments) <= 0:
logger.info(f"🔄 检测到 0 个场景切换,跳过切分,直接使用源文件")
# 直接复制源文件作为结果
import shutil
source_file = state.video_path
target_file = scenes_dir / f"{state.video_path.stem}_scene_001.mp4"
try:
shutil.copy2(source_file, target_file)
logger.info(f"✅ 源文件已复制到: {target_file}")
# 获取源文件元数据
metadata = self.splitter_service.get_video_metadata(str(source_file))
# 创建模拟的切分结果
slice_results = [(str(target_file), metadata)]
# 修正场景统计:无场景切换 = 1个场景整个视频
detection_result.total_scenes = 1
except Exception as e:
logger.error(f"❌ 复制源文件失败: {e}")
slice_results = []
else:
logger.info(f"🎬 检测到 {len(segments)} 个场景,开始切分")
# 生成输出路径
base_output_path = str(scenes_dir / f"{state.video_path.stem}_scene")
# 使用同步方法进行切分
slice_results = self.splitter_service.slice_video(
media_path=str(state.video_path),
segments=segments,
options=slice_options,
output_path=base_output_path
)
# 转换为兼容格式
split_results_raw = []
# 创建一个简单的结果对象
class SplitResult:
def __init__(self, scene_index, output_path, start_time, end_time, duration, file_size, success, error=None):
self.scene_index = scene_index
self.output_path = Path(output_path)
self.start_time = start_time
self.end_time = end_time
self.duration = duration
self.file_size = file_size
self.success = success
self.error = error
for i, (output_path, metadata) in enumerate(slice_results):
if len(detection_result.scenes) == 0:
# 无场景切换的情况:整个视频作为一个场景
split_result = SplitResult(
scene_index=0,
output_path=output_path,
start_time=0.0,
end_time=metadata.duration,
duration=metadata.duration,
file_size=metadata.size,
success=Path(output_path).exists(),
error=None if Path(output_path).exists() else "文件不存在"
)
split_results_raw.append(split_result)
else:
# 有场景切换的情况
scene = detection_result.scenes[i] if i < len(detection_result.scenes) else None
if scene:
split_result = SplitResult(
scene_index=i,
output_path=output_path,
start_time=scene.start_time,
end_time=scene.end_time,
duration=scene.duration,
file_size=metadata.size,
success=Path(output_path).exists(),
error=None if Path(output_path).exists() else "文件不存在"
)
split_results_raw.append(split_result)
# 4. 添加视频时长检查,如果时长大于最大视频时长那么就要进行二次切分
final_split_results = []
for split_result in split_results_raw:
try:
# 检查切分后的视频时长
video_path = str(split_result.output_path)
if split_result.duration > state.max_video_duration:
logger.info(f"⚠️ 片段 {split_result.scene_index + 1} 时长 {split_result.duration:.2f}s 超过限制 {state.max_video_duration:.2f}s进行二次切分")
# 进行二次切分
secondary_results = self.splitter_service.check_and_split_by_duration(
video_path=video_path,
max_duration=state.max_video_duration,
options=slice_options,
output_dir=str(split_result.output_path.parent)
)
# 处理二次切分结果
for i, (secondary_path, secondary_metadata) in enumerate(secondary_results):
if len(secondary_results) > 1:
# 如果进行了二次切分,创建新的结果对象
secondary_split_result = SplitResult(
scene_index=split_result.scene_index,
output_path=secondary_path,
start_time=split_result.start_time + (i * state.max_video_duration),
end_time=min(split_result.start_time + ((i + 1) * state.max_video_duration), split_result.end_time),
duration=secondary_metadata.duration,
file_size=secondary_metadata.size,
success=Path(secondary_path).exists(),
error=None if Path(secondary_path).exists() else "二次切分文件不存在"
)
final_split_results.append(secondary_split_result)
# 删除原始的过长片段(如果二次切分成功)
if Path(video_path).exists() and len(secondary_results) > 1:
try:
Path(video_path).unlink()
logger.info(f"🗑️ 已删除过长的原始片段: {Path(video_path).name}")
except Exception as e:
logger.warning(f"⚠️ 删除原始片段失败: {e}")
else:
# 如果没有进行二次切分(时长检查通过),保持原结果
final_split_results.append(split_result)
else:
# 时长未超过限制,直接添加到最终结果
final_split_results.append(split_result)
except Exception as e:
logger.error(f"❌ 处理片段 {split_result.scene_index + 1} 的时长检查时出错: {e}")
# 出错时保持原结果
final_split_results.append(split_result)
# 使用最终的切分结果
split_results_raw = final_split_results
# 更新统计信息
logger.info(f"📊 最终生成 {len(split_results_raw)} 个视频片段(包含二次切分)")
return split_results_raw
except Exception as e:
logger.error(f"❌ 视频切分失败: {e}")
return []
def _handle_project_material_import(self, state: SingleSceneDetectionWorkflowState, detection_result, split_results):
"""处理项目素材导入"""
try:
if not state.project_directory:
logger.warning("⚠️ 项目目录未设置,跳过素材导入")
return
# 使用项目素材管理服务
result = self.material_service.import_video_materials(
video_segments=split_results,
project_id=state.project_id,
project_directory=state.project_directory,
source_video_path=str(state.video_path),
material_tags=state.material_tags
)
if result["success"]:
logger.info(f"📝 项目素材导入完成: 新增 {result['imported_count']} 个素材,跳过 {result['skipped_count']} 个重复素材")
else:
logger.error(f"❌ 项目素材导入失败: {result.get('error', '未知错误')}")
except Exception as e:
logger.error(f"❌ 项目素材导入失败: {e}")

View File

@@ -0,0 +1,298 @@
"""
项目素材管理服务
"""
import json
import hashlib
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from python_core.utils.logger import logger
class ProjectMaterialService:
"""项目素材管理服务"""
def __init__(self):
logger.info("ProjectMaterialService 初始化完成")
def import_video_materials(
self,
video_segments: List[Any],
project_id: str,
project_directory: Path,
source_video_path: str,
material_tags: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
导入视频素材到项目
Args:
video_segments: 视频片段列表
project_id: 项目ID
project_directory: 项目目录
source_video_path: 源视频路径
material_tags: 素材标签
Returns:
Dict: 导入结果
"""
try:
# 创建项目素材目录
uncategorized_dir = project_directory / "未分类文件夹"
uncategorized_dir.mkdir(parents=True, exist_ok=True)
# 加载现有的项目素材列表
material_list_file = project_directory / "project_material.json"
existing_materials = self._load_material_list(material_list_file)
# 处理视频片段
new_materials = []
imported_count = 0
skipped_count = 0
for segment in video_segments:
if not segment.success:
continue
try:
result = self._import_single_segment(
segment=segment,
uncategorized_dir=uncategorized_dir,
existing_materials=existing_materials,
project_id=project_id,
source_video_path=source_video_path,
material_tags=material_tags or []
)
if result["imported"]:
new_materials.append(result["material_info"])
imported_count += 1
logger.info(f"✅ 素材已导入: {result['original_filename']} -> {result['target_filename']}")
else:
skipped_count += 1
logger.info(f"📋 素材已存在,跳过: {result['original_filename']}")
except Exception as e:
logger.error(f"❌ 导入素材失败 {segment.output_path.name}: {e}")
skipped_count += 1
# 更新项目素材列表
if new_materials:
existing_materials.extend(new_materials)
self._save_material_list(material_list_file, existing_materials)
logger.info(f"📝 项目素材列表已更新: 新增 {len(new_materials)} 个素材")
return {
"success": True,
"imported_count": imported_count,
"skipped_count": skipped_count,
"total_count": len(video_segments),
"new_materials": new_materials
}
except Exception as e:
logger.error(f"❌ 项目素材导入失败: {e}")
return {
"success": False,
"error": str(e),
"imported_count": 0,
"skipped_count": 0,
"total_count": len(video_segments)
}
def _import_single_segment(
self,
segment: Any,
uncategorized_dir: Path,
existing_materials: List[Dict],
project_id: str,
source_video_path: str,
material_tags: List[str]
) -> Dict[str, Any]:
"""导入单个视频片段"""
# 复制文件到项目目录
import shutil
temp_target_path = uncategorized_dir / f"temp_{segment.output_path.name}"
shutil.copy2(segment.output_path, temp_target_path)
# 计算复制后文件的MD5
file_md5 = self._calculate_file_md5(temp_target_path)
# 检查是否已存在相同MD5的素材
if any(material.get('md5') == file_md5 for material in existing_materials):
# 删除临时文件
temp_target_path.unlink()
return {
"imported": False,
"original_filename": segment.output_path.name,
"reason": "duplicate_md5"
}
# 重命名为最终文件名
target_filename = f"{file_md5}.mp4"
target_path = uncategorized_dir / target_filename
temp_target_path.rename(target_path)
# 创建素材信息
material_info = {
"id": file_md5,
"md5": file_md5,
"original_filename": segment.output_path.name,
"filename": target_filename,
"file_path": str(target_path),
"relative_path": f"未分类文件夹/{target_filename}",
"file_size": segment.file_size,
"duration": segment.duration,
"start_time": segment.start_time,
"end_time": segment.end_time,
"scene_index": segment.scene_index,
"tags": material_tags.copy(),
"created_at": datetime.now().isoformat(),
"use_count": 0,
"source_video": source_video_path,
"project_id": project_id
}
return {
"imported": True,
"material_info": material_info,
"original_filename": segment.output_path.name,
"target_filename": target_filename
}
def _load_material_list(self, material_list_file: Path) -> List[Dict]:
"""加载项目素材列表"""
if not material_list_file.exists():
return []
try:
with open(material_list_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning(f"⚠️ 读取现有素材列表失败: {e}")
return []
def _save_material_list(self, material_list_file: Path, materials: List[Dict]):
"""保存项目素材列表"""
try:
with open(material_list_file, 'w', encoding='utf-8') as f:
json.dump(materials, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"❌ 保存项目素材列表失败: {e}")
raise
def _calculate_file_md5(self, file_path: Path) -> str:
"""计算文件MD5值"""
hash_md5 = hashlib.md5()
try:
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
except Exception as e:
logger.error(f"❌ 计算MD5失败 {file_path}: {e}")
# 如果计算MD5失败使用文件名和大小作为备用标识
return hashlib.md5(f"{file_path.name}_{file_path.stat().st_size}".encode()).hexdigest()
def get_project_materials(self, project_directory: Path) -> List[Dict]:
"""获取项目素材列表"""
material_list_file = project_directory / "project_material.json"
return self._load_material_list(material_list_file)
def add_material_tags(self, project_directory: Path, material_id: str, tags: List[str]) -> bool:
"""为素材添加标签"""
try:
material_list_file = project_directory / "project_material.json"
materials = self._load_material_list(material_list_file)
for material in materials:
if material.get('id') == material_id:
existing_tags = set(material.get('tags', []))
new_tags = existing_tags.union(set(tags))
material['tags'] = list(new_tags)
self._save_material_list(material_list_file, materials)
logger.info(f"✅ 素材标签已更新: {material_id}")
return True
logger.warning(f"⚠️ 未找到素材: {material_id}")
return False
except Exception as e:
logger.error(f"❌ 添加素材标签失败: {e}")
return False
def remove_material(self, project_directory: Path, material_id: str) -> bool:
"""删除项目素材"""
try:
material_list_file = project_directory / "project_material.json"
materials = self._load_material_list(material_list_file)
# 查找要删除的素材
material_to_remove = None
for i, material in enumerate(materials):
if material.get('id') == material_id:
material_to_remove = materials.pop(i)
break
if material_to_remove:
# 删除文件
file_path = Path(material_to_remove['file_path'])
if file_path.exists():
file_path.unlink()
logger.info(f"🗑️ 已删除素材文件: {file_path.name}")
# 更新素材列表
self._save_material_list(material_list_file, materials)
logger.info(f"✅ 素材已删除: {material_id}")
return True
else:
logger.warning(f"⚠️ 未找到素材: {material_id}")
return False
except Exception as e:
logger.error(f"❌ 删除素材失败: {e}")
return False
def get_material_stats(self, project_directory: Path) -> Dict[str, Any]:
"""获取项目素材统计信息"""
try:
materials = self.get_project_materials(project_directory)
total_count = len(materials)
total_size = sum(material.get('file_size', 0) for material in materials)
total_duration = sum(material.get('duration', 0) for material in materials)
# 按标签统计
tag_stats = {}
for material in materials:
for tag in material.get('tags', []):
tag_stats[tag] = tag_stats.get(tag, 0) + 1
# 按使用次数统计
used_count = sum(1 for material in materials if material.get('use_count', 0) > 0)
unused_count = total_count - used_count
return {
"total_count": total_count,
"total_size": total_size,
"total_duration": total_duration,
"used_count": used_count,
"unused_count": unused_count,
"tag_stats": tag_stats
}
except Exception as e:
logger.error(f"❌ 获取素材统计失败: {e}")
return {
"total_count": 0,
"total_size": 0,
"total_duration": 0,
"used_count": 0,
"unused_count": 0,
"tag_stats": {}
}