feat: 实现AI生成视频前置跳过功能 v0.1.35
- 添加skip_start_ms参数到CreateMaterialRequest和MaterialProcessingConfig - 在MaterialImportDialog中添加前置跳过毫秒数输入框 - 实现FFmpegService::create_trimmed_video方法创建跳过开头的临时视频 - 在场景检测前处理视频前置跳过,避免AI生成视频相同首帧问题 - 支持同步和异步处理模式,自动调整场景时间戳补偿跳过时间 - 自动清理临时文件,确保资源管理正确 解决问题: - AI生成视频第一帧相同导致切片后视频首帧重复 - 通过跳过前置毫秒数避免相同首帧进入最终切片结果
This commit is contained in:
parent
9c3f7341aa
commit
3dbdca4ee6
|
|
@ -306,9 +306,61 @@ impl AsyncMaterialService {
|
|||
|
||||
let original_path = material.original_path.clone();
|
||||
let threshold = config.scene_detection_threshold;
|
||||
|
||||
let skip_start_ms = config.skip_start_ms;
|
||||
|
||||
match task::spawn_blocking(move || {
|
||||
MaterialService::detect_video_scenes(&original_path, threshold)
|
||||
// 如果设置了跳过开头,先创建临时视频文件
|
||||
let detection_file_path = if let Some(skip_ms) = skip_start_ms {
|
||||
if skip_ms > 0 {
|
||||
println!("异步处理 - AI生成视频前置跳过: {}ms", skip_ms);
|
||||
match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&original_path, skip_ms) {
|
||||
Ok(temp_path) => {
|
||||
println!("异步处理 - 临时视频创建成功: {}", temp_path);
|
||||
temp_path
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("异步处理 - 创建临时视频失败,使用原视频: {}", e);
|
||||
original_path.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
original_path.clone()
|
||||
}
|
||||
} else {
|
||||
original_path.clone()
|
||||
};
|
||||
|
||||
let result = MaterialService::detect_video_scenes(&detection_file_path, threshold);
|
||||
|
||||
// 处理结果并调整时间戳
|
||||
let final_result = match result {
|
||||
Ok(mut scene_detection) => {
|
||||
// 如果使用了临时文件,需要调整场景时间戳
|
||||
if let Some(skip_ms) = skip_start_ms {
|
||||
if skip_ms > 0 && detection_file_path != original_path {
|
||||
let skip_seconds = skip_ms as f64 / 1000.0;
|
||||
println!("异步处理 - 调整场景时间戳,补偿跳过的{}秒", skip_seconds);
|
||||
for scene in &mut scene_detection.scenes {
|
||||
scene.start_time += skip_seconds;
|
||||
scene.end_time += skip_seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(scene_detection)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
|
||||
// 清理临时文件
|
||||
if detection_file_path != original_path {
|
||||
if let Err(e) = std::fs::remove_file(&detection_file_path) {
|
||||
eprintln!("异步处理 - 清理临时文件失败: {}", e);
|
||||
} else {
|
||||
println!("异步处理 - 临时文件清理成功: {}", detection_file_path);
|
||||
}
|
||||
}
|
||||
|
||||
final_result
|
||||
}).await? {
|
||||
Ok(scene_detection) => {
|
||||
info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
||||
|
|
|
|||
|
|
@ -344,15 +344,65 @@ impl MaterialService {
|
|||
// 2. 场景检测(如果是视频且启用了场景检测)
|
||||
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
|
||||
println!("开始视频场景检测: {}", material.original_path);
|
||||
match Self::detect_video_scenes(&material.original_path, config.scene_detection_threshold) {
|
||||
Ok(scene_detection) => {
|
||||
|
||||
// 如果设置了跳过开头,先创建临时视频文件
|
||||
let detection_file_path = if let Some(skip_ms) = config.skip_start_ms {
|
||||
if skip_ms > 0 {
|
||||
println!("AI生成视频前置跳过: {}ms", skip_ms);
|
||||
match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&material.original_path, skip_ms) {
|
||||
Ok(temp_path) => {
|
||||
println!("临时视频创建成功,用于场景检测: {}", temp_path);
|
||||
temp_path
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("创建临时视频失败,使用原视频: {}", e);
|
||||
material.original_path.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
material.original_path.clone()
|
||||
}
|
||||
} else {
|
||||
material.original_path.clone()
|
||||
};
|
||||
|
||||
match Self::detect_video_scenes(&detection_file_path, config.scene_detection_threshold) {
|
||||
Ok(mut scene_detection) => {
|
||||
// 如果使用了临时文件,需要调整场景时间戳
|
||||
if let Some(skip_ms) = config.skip_start_ms {
|
||||
if skip_ms > 0 && detection_file_path != material.original_path {
|
||||
let skip_seconds = skip_ms as f64 / 1000.0;
|
||||
println!("调整场景时间戳,补偿跳过的{}秒", skip_seconds);
|
||||
for scene in &mut scene_detection.scenes {
|
||||
scene.start_time += skip_seconds;
|
||||
scene.end_time += skip_seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
||||
material.set_scene_detection(scene_detection);
|
||||
repository.update(&material)?;
|
||||
|
||||
// 清理临时文件
|
||||
if detection_file_path != material.original_path {
|
||||
if let Err(e) = std::fs::remove_file(&detection_file_path) {
|
||||
eprintln!("清理临时文件失败: {}", e);
|
||||
} else {
|
||||
println!("临时文件清理成功: {}", detection_file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 场景检测失败不应该导致整个处理失败
|
||||
eprintln!("场景检测失败: {}", e);
|
||||
|
||||
// 清理临时文件
|
||||
if detection_file_path != material.original_path {
|
||||
if let Err(e) = std::fs::remove_file(&detection_file_path) {
|
||||
eprintln!("清理临时文件失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ pub struct CreateMaterialRequest {
|
|||
pub auto_process: bool,
|
||||
pub max_segment_duration: Option<f64>, // 最大片段时长(秒)
|
||||
pub model_id: Option<String>, // 可选的模特绑定ID
|
||||
pub skip_start_ms: Option<u32>, // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
|
||||
}
|
||||
|
||||
/// 视频切分模式
|
||||
|
|
@ -174,6 +175,7 @@ pub struct MaterialProcessingConfig {
|
|||
pub output_format: String, // 输出格式
|
||||
pub auto_process: Option<bool>, // 是否自动处理
|
||||
pub split_mode: VideoSplitMode, // 视频切分模式
|
||||
pub skip_start_ms: Option<u32>, // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
|
||||
}
|
||||
|
||||
impl Default for MaterialProcessingConfig {
|
||||
|
|
@ -187,6 +189,7 @@ impl Default for MaterialProcessingConfig {
|
|||
output_format: "mp4".to_string(),
|
||||
auto_process: Some(true),
|
||||
split_mode: VideoSplitMode::Accurate, // 默认使用精确模式
|
||||
skip_start_ms: None, // 默认不跳过开头
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,61 @@ impl FFmpegService {
|
|||
None
|
||||
}
|
||||
|
||||
/// 创建跳过开头指定毫秒数的临时视频文件
|
||||
/// 返回临时文件路径,调用者负责清理
|
||||
pub fn create_trimmed_video(input_path: &str, skip_start_ms: u32) -> Result<String> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
if skip_start_ms == 0 {
|
||||
// 如果不需要跳过,直接返回原文件路径
|
||||
return Ok(input_path.to_string());
|
||||
}
|
||||
|
||||
// 创建临时文件路径
|
||||
let input_path_obj = Path::new(input_path);
|
||||
let file_stem = input_path_obj.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("temp");
|
||||
let extension = input_path_obj.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("mp4");
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file = temp_dir.join(format!("{}_trimmed_{}.{}", file_stem, skip_start_ms, extension));
|
||||
let temp_path = temp_file.to_string_lossy().to_string();
|
||||
|
||||
println!("创建跳过前{}ms的临时视频: {} -> {}", skip_start_ms, input_path, temp_path);
|
||||
|
||||
// 使用FFmpeg跳过开头指定毫秒数
|
||||
let skip_seconds = skip_start_ms as f64 / 1000.0;
|
||||
let output = Self::create_hidden_command("ffmpeg")
|
||||
.args([
|
||||
"-i", input_path,
|
||||
"-ss", &skip_seconds.to_string(), // 跳过开头
|
||||
"-c", "copy", // 流复制,避免重新编码
|
||||
"-avoid_negative_ts", "make_zero", // 避免负时间戳
|
||||
"-y", // 覆盖输出文件
|
||||
&temp_path
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("执行视频跳过失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("FFmpeg视频跳过失败: {}", stderr));
|
||||
}
|
||||
|
||||
// 验证输出文件是否存在
|
||||
if !Path::new(&temp_path).exists() {
|
||||
return Err(anyhow!("临时视频文件创建失败: {}", temp_path));
|
||||
}
|
||||
|
||||
println!("临时视频创建成功: {}", temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// 检测视频场景变化
|
||||
pub fn detect_scenes(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
|
||||
if !Path::new(file_path).exists() {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ pub async fn import_materials(
|
|||
if let Some(max_duration) = request.max_segment_duration {
|
||||
config.max_segment_duration = max_duration;
|
||||
}
|
||||
if let Some(skip_ms) = request.skip_start_ms {
|
||||
config.skip_start_ms = Some(skip_ms);
|
||||
}
|
||||
|
||||
MaterialService::import_materials(repository, request, &config)
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [autoProcess, setAutoProcess] = useState(true);
|
||||
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
|
||||
const [maxSegmentDuration, setMaxSegmentDuration] = useState(3); // 5分钟
|
||||
const [skipStartMs, setSkipStartMs] = useState(200); // 跳过开头毫秒数
|
||||
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
|
||||
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
|
||||
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
|
||||
|
|
@ -208,6 +209,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
auto_process: autoProcess,
|
||||
max_segment_duration: maxSegmentDuration,
|
||||
model_id: selectedModelId || undefined,
|
||||
skip_start_ms: skipStartMs > 0 ? skipStartMs : undefined,
|
||||
};
|
||||
|
||||
console.log('前端准备导入,选择的模特ID:', selectedModelId);
|
||||
|
|
@ -444,6 +446,26 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
选择模特后,导入的素材将自动绑定到该模特
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI生成视频前置跳过设置 */}
|
||||
<div>
|
||||
<label htmlFor="skipStartMs" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
跳过视频开头(毫秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="skipStartMs"
|
||||
value={skipStartMs}
|
||||
onChange={(e) => setSkipStartMs(Number(e.target.value))}
|
||||
min="0"
|
||||
max="10000"
|
||||
step="100"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
AI生成的视频通常前几帧相同,设置跳过毫秒数可避免切片后首帧重复问题
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export interface CreateMaterialRequest {
|
|||
auto_process: boolean;
|
||||
max_segment_duration?: number;
|
||||
model_id?: string; // 可选的模特绑定ID
|
||||
skip_start_ms?: number; // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
|
||||
}
|
||||
|
||||
export interface MaterialProcessingConfig {
|
||||
|
|
|
|||
Loading…
Reference in New Issue