feat: 实现AI生成视频前置跳过功能 v0.1.35

- 添加skip_start_ms参数到CreateMaterialRequest和MaterialProcessingConfig
- 在MaterialImportDialog中添加前置跳过毫秒数输入框
- 实现FFmpegService::create_trimmed_video方法创建跳过开头的临时视频
- 在场景检测前处理视频前置跳过,避免AI生成视频相同首帧问题
- 支持同步和异步处理模式,自动调整场景时间戳补偿跳过时间
- 自动清理临时文件,确保资源管理正确

解决问题:
- AI生成视频第一帧相同导致切片后视频首帧重复
- 通过跳过前置毫秒数避免相同首帧进入最终切片结果
This commit is contained in:
imeepos 2025-07-18 11:11:10 +08:00
parent 9c3f7341aa
commit 3dbdca4ee6
7 changed files with 191 additions and 5 deletions

View File

@ -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());

View File

@ -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 {

View File

@ -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, // 默认不跳过开头
}
}
}

View File

@ -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() {

View File

@ -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())

View File

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

View File

@ -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 {