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 original_path = material.original_path.clone();
|
||||||
let threshold = config.scene_detection_threshold;
|
let threshold = config.scene_detection_threshold;
|
||||||
|
let skip_start_ms = config.skip_start_ms;
|
||||||
|
|
||||||
match task::spawn_blocking(move || {
|
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? {
|
}).await? {
|
||||||
Ok(scene_detection) => {
|
Ok(scene_detection) => {
|
||||||
info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
||||||
|
|
|
||||||
|
|
@ -344,15 +344,65 @@ impl MaterialService {
|
||||||
// 2. 场景检测(如果是视频且启用了场景检测)
|
// 2. 场景检测(如果是视频且启用了场景检测)
|
||||||
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
|
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
|
||||||
println!("开始视频场景检测: {}", material.original_path);
|
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());
|
println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
|
||||||
material.set_scene_detection(scene_detection);
|
material.set_scene_detection(scene_detection);
|
||||||
repository.update(&material)?;
|
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) => {
|
Err(e) => {
|
||||||
// 场景检测失败不应该导致整个处理失败
|
// 场景检测失败不应该导致整个处理失败
|
||||||
eprintln!("场景检测失败: {}", e);
|
eprintln!("场景检测失败: {}", e);
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
if detection_file_path != material.original_path {
|
||||||
|
if let Err(e) = std::fs::remove_file(&detection_file_path) {
|
||||||
|
eprintln!("清理临时文件失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ pub struct CreateMaterialRequest {
|
||||||
pub auto_process: bool,
|
pub auto_process: bool,
|
||||||
pub max_segment_duration: Option<f64>, // 最大片段时长(秒)
|
pub max_segment_duration: Option<f64>, // 最大片段时长(秒)
|
||||||
pub model_id: Option<String>, // 可选的模特绑定ID
|
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 output_format: String, // 输出格式
|
||||||
pub auto_process: Option<bool>, // 是否自动处理
|
pub auto_process: Option<bool>, // 是否自动处理
|
||||||
pub split_mode: VideoSplitMode, // 视频切分模式
|
pub split_mode: VideoSplitMode, // 视频切分模式
|
||||||
|
pub skip_start_ms: Option<u32>, // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MaterialProcessingConfig {
|
impl Default for MaterialProcessingConfig {
|
||||||
|
|
@ -187,6 +189,7 @@ impl Default for MaterialProcessingConfig {
|
||||||
output_format: "mp4".to_string(),
|
output_format: "mp4".to_string(),
|
||||||
auto_process: Some(true),
|
auto_process: Some(true),
|
||||||
split_mode: VideoSplitMode::Accurate, // 默认使用精确模式
|
split_mode: VideoSplitMode::Accurate, // 默认使用精确模式
|
||||||
|
skip_start_ms: None, // 默认不跳过开头
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,61 @@ impl FFmpegService {
|
||||||
None
|
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>> {
|
pub fn detect_scenes(file_path: &str, threshold: f64) -> Result<Vec<f64>> {
|
||||||
if !Path::new(file_path).exists() {
|
if !Path::new(file_path).exists() {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ pub async fn import_materials(
|
||||||
if let Some(max_duration) = request.max_segment_duration {
|
if let Some(max_duration) = request.max_segment_duration {
|
||||||
config.max_segment_duration = max_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)
|
MaterialService::import_materials(repository, request, &config)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||||
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||||
const [autoProcess, setAutoProcess] = useState(true);
|
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 [ffmpegAvailable, setFFmpegAvailable] = useState(false);
|
||||||
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
|
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
|
||||||
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
|
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
|
||||||
|
|
@ -208,6 +209,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||||
auto_process: autoProcess,
|
auto_process: autoProcess,
|
||||||
max_segment_duration: maxSegmentDuration,
|
max_segment_duration: maxSegmentDuration,
|
||||||
model_id: selectedModelId || undefined,
|
model_id: selectedModelId || undefined,
|
||||||
|
skip_start_ms: skipStartMs > 0 ? skipStartMs : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('前端准备导入,选择的模特ID:', selectedModelId);
|
console.log('前端准备导入,选择的模特ID:', selectedModelId);
|
||||||
|
|
@ -444,6 +446,26 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
||||||
选择模特后,导入的素材将自动绑定到该模特
|
选择模特后,导入的素材将自动绑定到该模特
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ export interface CreateMaterialRequest {
|
||||||
auto_process: boolean;
|
auto_process: boolean;
|
||||||
max_segment_duration?: number;
|
max_segment_duration?: number;
|
||||||
model_id?: string; // 可选的模特绑定ID
|
model_id?: string; // 可选的模特绑定ID
|
||||||
|
skip_start_ms?: number; // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaterialProcessingConfig {
|
export interface MaterialProcessingConfig {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue