From c7f9c9f4bb60137d61145dfb8f97d82f2b849323 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 16 Jul 2025 00:25:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BAMaterialCard=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96UI=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为Material数据模型添加thumbnail_path字段 - 实现get_material_thumbnail_base64 API命令支持Material缩略图生成 - 创建MaterialThumbnail组件,支持懒加载和缓存机制 - 重新设计MaterialCard布局,使用缩略图替换文件类型图标 - 精简MaterialCard信息展示,将详细信息移到可折叠区域 - 优化按钮布局,使界面更加紧凑 - 简化切分片段显示方式,提升用户体验 - 修复数据库DateTime解析问题,支持SQLite和RFC3339两种格式 - 添加数据库迁移支持thumbnail_path字段 - 遵循promptx/tauri-desktop-app-expert开发规范 --- .../src-tauri/src/data/models/material.rs | 3 + .../data/repositories/material_repository.rs | 128 ++++++++--- .../src-tauri/src/infrastructure/database.rs | 11 + apps/desktop/src-tauri/src/lib.rs | 1 + .../commands/database_commands.rs | 2 + .../commands/material_commands.rs | 171 ++++++++++++++ apps/desktop/src/components/MaterialCard.tsx | 217 ++++++++++-------- .../src/components/MaterialThumbnail.tsx | 135 +++++++++++ apps/desktop/src/components/ProjectList.tsx | 1 + apps/desktop/src/types/material.ts | 1 + 10 files changed, 548 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/src/components/MaterialThumbnail.tsx diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs index c571c11..1391dd9 100644 --- a/apps/desktop/src-tauri/src/data/models/material.rs +++ b/apps/desktop/src-tauri/src/data/models/material.rs @@ -114,6 +114,7 @@ pub struct Material { pub metadata: MaterialMetadata, pub scene_detection: Option, pub segments: Vec, + pub thumbnail_path: Option, // 素材缩略图路径 pub created_at: DateTime, pub updated_at: DateTime, pub processed_at: Option>, @@ -236,6 +237,7 @@ impl Material { metadata: MaterialMetadata::None, scene_detection: None, segments: Vec::new(), + thumbnail_path: None, created_at: now, updated_at: now, processed_at: None, @@ -267,6 +269,7 @@ impl Material { metadata: MaterialMetadata::None, scene_detection: None, segments: Vec::new(), + thumbnail_path: None, created_at: now, updated_at: now, processed_at: None, diff --git a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs index 9fa67a9..32dbbf6 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs @@ -70,7 +70,7 @@ impl MaterialRepository { let mut stmt = conn.prepare( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials WHERE id = ?1" )?; @@ -95,7 +95,7 @@ impl MaterialRepository { let mut stmt = conn.prepare( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials WHERE project_id = ?1 ORDER BY created_at DESC" )?; @@ -122,7 +122,7 @@ impl MaterialRepository { let mut stmt = conn.prepare( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials ORDER BY created_at DESC" )?; @@ -371,27 +371,53 @@ impl MaterialRepository { metadata, scene_detection, segments: Vec::new(), // 需要单独查询 - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?) - .map_err(|e| rusqlite::Error::InvalidColumnType( - row.as_ref().column_index("created_at").unwrap(), - format!("DateTime parse error: {}", e), - rusqlite::types::Type::Text - ))?.with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("updated_at")?) - .map_err(|e| rusqlite::Error::InvalidColumnType( - row.as_ref().column_index("updated_at").unwrap(), - format!("DateTime parse error: {}", e), - rusqlite::types::Type::Text - ))?.with_timezone(&chrono::Utc), + thumbnail_path: row.get("thumbnail_path")?, + created_at: { + let created_at_str = row.get::<_, String>("created_at")?; + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { + dt.and_utc() + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("created_at").unwrap(), + format!("DateTime parse error: {}", e), + rusqlite::types::Type::Text + ))?.with_timezone(&chrono::Utc) + } + }, + updated_at: { + let updated_at_str = row.get::<_, String>("updated_at")?; + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&updated_at_str, "%Y-%m-%d %H:%M:%S") { + dt.and_utc() + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&updated_at_str) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("updated_at").unwrap(), + format!("DateTime parse error: {}", e), + rusqlite::types::Type::Text + ))?.with_timezone(&chrono::Utc) + } + }, processed_at: row.get::<_, Option>("processed_at")? - .map(|s| chrono::DateTime::parse_from_rfc3339(&s)) + .map(|s| { + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") { + Ok(dt.and_utc()) + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&chrono::Utc)) + } + }) .transpose() .map_err(|e| rusqlite::Error::InvalidColumnType( row.as_ref().column_index("processed_at").unwrap(), format!("DateTime parse error: {}", e), rusqlite::types::Type::Text - ))? - .map(|dt| dt.with_timezone(&chrono::Utc)), + ))?, error_message: row.get("error_message")?, }) } @@ -408,12 +434,21 @@ impl MaterialRepository { file_path: row.get("file_path")?, file_size: row.get::<_, i64>("file_size")? as u64, thumbnail_path: row.get("thumbnail_path")?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?) - .map_err(|e| rusqlite::Error::InvalidColumnType( - row.as_ref().column_index("created_at").unwrap(), - format!("DateTime parse error: {}", e), - rusqlite::types::Type::Text - ))?.with_timezone(&chrono::Utc), + created_at: { + let created_at_str = row.get::<_, String>("created_at")?; + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { + dt.and_utc() + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|e| rusqlite::Error::InvalidColumnType( + row.as_ref().column_index("created_at").unwrap(), + format!("DateTime parse error: {}", e), + rusqlite::types::Type::Text + ))?.with_timezone(&chrono::Utc) + } + }, }) } @@ -451,7 +486,7 @@ impl MaterialRepository { let mut stmt = conn.prepare( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials WHERE model_id = ?1 ORDER BY created_at DESC" )?; @@ -476,7 +511,7 @@ impl MaterialRepository { ( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials WHERE model_id IS NULL AND project_id = ?1 ORDER BY created_at DESC".to_string(), vec![pid] ) @@ -484,7 +519,7 @@ impl MaterialRepository { ( "SELECT id, project_id, model_id, name, original_path, file_size, md5_hash, material_type, processing_status, metadata, scene_detection, - created_at, updated_at, processed_at, error_message + thumbnail_path, created_at, updated_at, processed_at, error_message FROM materials WHERE model_id IS NULL ORDER BY created_at DESC".to_string(), vec![] ) @@ -595,7 +630,18 @@ impl MaterialRepository { file_path: row.get(6)?, file_size: row.get(7)?, thumbnail_path: row.get(8)?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc), + created_at: { + let created_at_str = row.get::<_, String>(9)?; + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { + dt.and_utc() + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&chrono::Utc) + } + }, }) })?; @@ -627,7 +673,18 @@ impl MaterialRepository { file_path: row.get(6)?, file_size: row.get(7)?, thumbnail_path: row.get(8)?, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc), + created_at: { + let created_at_str = row.get::<_, String>(9)?; + // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { + dt.and_utc() + } else { + // 回退到RFC3339格式 + chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&chrono::Utc) + } + }, }) })?; @@ -773,6 +830,19 @@ impl MaterialRepository { Ok(count as u32) } + + /// 更新素材的缩略图路径 + pub fn update_material_thumbnail(&self, material_id: &str, thumbnail_path: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE materials SET thumbnail_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [thumbnail_path, material_id], + )?; + + Ok(()) + } } /// 模特素材统计信息 diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 27b41c9..2ac2409 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -1236,6 +1236,17 @@ impl Database { println!("Added thumbnail_path column to material_segments table"); } + // 添加缩略图路径字段到素材表 + let has_material_thumbnail_path_column = conn.prepare("SELECT thumbnail_path FROM materials LIMIT 1").is_ok(); + if !has_material_thumbnail_path_column { + println!("Adding thumbnail_path column to materials table"); + conn.execute( + "ALTER TABLE materials ADD COLUMN thumbnail_path TEXT", + [], + )?; + println!("Added thumbnail_path column to materials table"); + } + // 暂时禁用自动清理,避免启动时卡住 // self.cleanup_invalid_projects()?; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 76a50f2..e81e224 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -75,6 +75,7 @@ pub fn run() { commands::material_commands::generate_video_thumbnail, commands::material_commands::generate_and_save_segment_thumbnail, commands::material_commands::read_thumbnail_as_data_url, + commands::material_commands::get_material_thumbnail_base64, commands::material_commands::get_segment_thumbnail_base64, commands::material_commands::test_scene_detection, commands::material_commands::get_material_segments, diff --git a/apps/desktop/src-tauri/src/presentation/commands/database_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/database_commands.rs index 0ea18ee..911b917 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/database_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/database_commands.rs @@ -156,3 +156,5 @@ pub fn debug_database_data(state: State, project_id: String) -> Result Ok(result) } + + diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs index 36cf329..225b633 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -904,6 +904,177 @@ pub async fn read_thumbnail_as_data_url(file_path: String) -> Result, +) -> Result { + use crate::infrastructure::ffmpeg::FFmpegService; + use std::path::Path; + use std::fs; + use base64::{Engine as _, engine::general_purpose}; + + // 获取素材信息 + let material = { + let material_repository_guard = app_state.material_repository.lock().unwrap(); + let material_repository = material_repository_guard.as_ref() + .ok_or("MaterialRepository未初始化")?; + + material_repository.get_by_id(&material_id) + .map_err(|e| e.to_string())? + }; + + let material = match material { + Some(m) => m, + None => return Err("素材不存在".to_string()), + }; + + // 检查数据库中是否已有缩略图路径 + if let Some(ref thumbnail_path) = material.thumbnail_path { + // 去掉Windows长路径前缀 + let clean_path = if thumbnail_path.starts_with("\\\\?\\") { + &thumbnail_path[4..] + } else { + thumbnail_path + }; + + // 检查文件是否存在 + if Path::new(clean_path).exists() { + // 文件存在,直接读取并返回 + let file_data = fs::read(clean_path) + .map_err(|e| format!("读取缩略图文件失败: {}", e))?; + + let base64_data = general_purpose::STANDARD.encode(&file_data); + return Ok(format!("data:image/jpeg;base64,{}", base64_data)); + } + } + + // 缩略图不存在或文件已丢失,需要重新生成 + let video_path = &material.original_path; + + // 去掉Windows长路径前缀 + let clean_video_path = if video_path.starts_with("\\\\?\\") { + &video_path[4..] + } else { + video_path + }; + + // 检查视频文件是否存在,不存在则报错 + if !Path::new(clean_video_path).exists() { + let error_msg = format!("视频文件不存在,无法生成缩略图: {}", clean_video_path); + tracing::error!( + material_id = %material_id, + video_path = %clean_video_path, + "视频文件不存在" + ); + return Err(error_msg); + } + + // 生成缩略图路径 + let thumbnail_filename = format!("{}_material_thumbnail.jpg", material.id); + let video_dir = Path::new(clean_video_path).parent() + .ok_or("无法获取视频文件目录")?; + let thumbnail_path = video_dir.join(thumbnail_filename); + let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string(); + + // 获取视频信息来确定合适的缩略图尺寸 + let video_info = FFmpegService::get_video_info(clean_video_path) + .map_err(|e| e.to_string())?; + + // 计算缩略图尺寸,保持宽高比,最大宽度160 + let max_width = 160; + let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 { + let aspect_ratio = video_info.width as f64 / video_info.height as f64; + if video_info.width > max_width { + let new_width = max_width; + let new_height = (new_width as f64 / aspect_ratio).round() as u32; + (new_width, new_height) + } else { + (video_info.width, video_info.height) + } + } else { + // 如果无法获取视频尺寸,使用默认值 + (160, 120) + }; + + // 生成缩略图(使用首帧,带重试机制) + let timestamp = 0.0; // 使用视频开始时间 + + // 先进行预检查 + if let Err(e) = FFmpegService::validate_thumbnail_generation( + clean_video_path, + &thumbnail_path_str, + timestamp + ) { + let error_msg = format!( + "缩略图生成预检查失败: {} (视频: {})", + e, clean_video_path + ); + tracing::error!( + material_id = %material_id, + video_path = %clean_video_path, + error = %e, + "缩略图生成预检查失败" + ); + return Err(error_msg); + } + + // 使用带重试机制的缩略图生成 + if let Err(e) = FFmpegService::generate_thumbnail_with_retry( + clean_video_path, + &thumbnail_path_str, + timestamp, + thumb_width, + thumb_height + ) { + let error_msg = format!( + "FFmpeg生成缩略图失败(已重试): {} (视频: {}, 输出: {}, 时间戳: {}s)", + e, clean_video_path, thumbnail_path_str, timestamp + ); + tracing::error!( + material_id = %material_id, + video_path = %clean_video_path, + thumbnail_path = %thumbnail_path_str, + timestamp = timestamp, + error = %e, + "FFmpeg缩略图生成失败(已重试)" + ); + return Err(error_msg); + } + + // 验证缩略图文件是否生成成功 + if !Path::new(&thumbnail_path_str).exists() { + let error_msg = format!( + "缩略图文件生成失败,文件不存在: {}", + thumbnail_path_str + ); + tracing::error!( + material_id = %material_id, + thumbnail_path = %thumbnail_path_str, + "缩略图文件生成失败" + ); + return Err(error_msg); + } + + // 保存缩略图路径到数据库 + { + let material_repository_guard = app_state.material_repository.lock().unwrap(); + let material_repository = material_repository_guard.as_ref() + .ok_or("MaterialRepository未初始化")?; + + material_repository.update_material_thumbnail(&material_id, &thumbnail_path_str) + .map_err(|e| e.to_string())?; + } + + // 读取生成的缩略图文件并返回base64 + let file_data = fs::read(&thumbnail_path_str) + .map_err(|e| format!("读取生成的缩略图失败: {}", e))?; + + let base64_data = general_purpose::STANDARD.encode(&file_data); + Ok(format!("data:image/jpeg;base64,{}", base64_data)) +} + /// 根据segmentId获取缩略图base64数据URL #[command] pub async fn get_segment_thumbnail_base64( diff --git a/apps/desktop/src/components/MaterialCard.tsx b/apps/desktop/src/components/MaterialCard.tsx index 0451946..ad6adb2 100644 --- a/apps/desktop/src/components/MaterialCard.tsx +++ b/apps/desktop/src/components/MaterialCard.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { - FileVideo, FileAudio, FileImage, File, Clock, ExternalLink, ChevronDown, ChevronUp, + FileVideo, FileAudio, FileImage, Clock, ExternalLink, ChevronDown, ChevronUp, Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2, User, Edit2, Trash2, RefreshCw } from 'lucide-react'; import { Material, MaterialSegment } from '../types/material'; @@ -9,6 +9,7 @@ import { useVideoClassificationStore } from '../store/videoClassificationStore'; import { Model } from '../types/model'; import { invoke } from '@tauri-apps/api/core'; import { DeleteConfirmDialog } from './DeleteConfirmDialog'; +import { MaterialThumbnail } from './MaterialThumbnail'; interface MaterialCardProps { material: Material; @@ -73,22 +74,12 @@ export const MaterialCard: React.FC = ({ material, onEdit, on const [associatedModel, setAssociatedModel] = useState(null); const [loadingModel, setLoadingModel] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [thumbnailCache, setThumbnailCache] = useState>(new Map()); + const [showDetails, setShowDetails] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isReprocessing, setIsReprocessing] = useState(false); - // 获取素材类型图标 - const getTypeIcon = (type: string) => { - switch (type) { - case 'Video': - return ; - case 'Audio': - return ; - case 'Image': - return ; - default: - return ; - } - }; + // 获取状态颜色 const getStatusColor = (status: string) => { @@ -272,72 +263,124 @@ export const MaterialCard: React.FC = ({ material, onEdit, on }; return ( -
+
{/* 素材基本信息 */} -
-
- {getTypeIcon(material.material_type)} -

{material.name}

-
-
- {/* 重新处理按钮 - 仅在状态为 Pending 时显示 */} - {material.processing_status === 'Pending' && onReprocess && ( - )} - - )} - {onEdit && ( - - )} + {onEdit && ( + + )} - {onDelete && ( - - )} + {onDelete && ( + + )} - - {material.processing_status} - + + {material.processing_status} + +
+
- {/* 素材详细信息 */} -
- {/* 基本信息 */} -
-
- - {formatFileSize(material.file_size)} -
-
- - {formatDate(material.created_at)} -
+ {/* 快速操作区域 */} +
+
+ {/* AI分类按钮 */} + {material.material_type === 'Video' && material.processing_status === 'Completed' && ( + + )}
- {/* 关联模特信息 */} - {material.model_id && ( + {/* 展开/折叠详细信息按钮 */} + +
+ + {/* 素材详细信息 - 可折叠 */} + {showDetails && ( +
+ {/* 关联模特信息 */} + {material.model_id && (
@@ -490,7 +533,8 @@ export const MaterialCard: React.FC = ({ material, onEdit, on
)} -
+
+ )} {/* 切分片段控制 */} {material.material_type === 'Video' && material.processing_status === 'Completed' && ( @@ -511,34 +555,21 @@ export const MaterialCard: React.FC = ({ material, onEdit, on {loadingSegments ? '加载中...' : showSegments ? '隐藏片段' : '查看切分片段'} - {/* AI智能分类按钮 */} - +
{/* 切分片段列表 */} {showSegments && segments.length > 0 && ( -
-
切分片段 ({segments.length})
-
+
+
+ 共 {segments.length} 个片段,总时长 {formatTime(segments.reduce((total, seg) => total + seg.duration, 0))} +
+
{segments.map((segment) => ( -
-
+
+
#{segment.segment_index + 1} - - {formatTime(segment.start_time)} - {formatTime(segment.end_time)} - ({formatTime(segment.duration)}) + {formatTime(segment.duration)}