Add VideoWall feature: server-side preview clip generation and mobile grid view
Backend (Rust/Actix-web): - Add video_preview_clips table and PreviewDao for tracking preview generation - Add ffmpeg preview clip generator: 10 equally-spaced 1s segments at 480p with CUDA NVENC auto-detection - Add PreviewClipGenerator actor with semaphore-limited concurrent processing - Add GET /video/preview and POST /video/preview/status endpoints - Extend file watcher to detect and queue previews for new videos - Use relative paths consistently for DB storage (matching EXIF convention) Frontend (React Native/Expo): - Add VideoWall grid view with 2-3 column layout of looping preview clips - Add VideoWallItem component with ActiveVideoPlayer sub-component for lifecycle management - Add useVideoWall hook for batch status polling with 5s refresh - Add navigation button in grid header (visible when videos exist) - Use TextureView surface type to fix Android z-ordering issues - Optimize memory: players only mount while visible via FlatList windowSize - Configure ExoPlayer buffer options and caching for short clips - Tap to toggle audio focus, long press to open in full viewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ pub mod daily_summary_dao;
|
||||
pub mod insights_dao;
|
||||
pub mod location_dao;
|
||||
pub mod models;
|
||||
pub mod preview_dao;
|
||||
pub mod schema;
|
||||
pub mod search_dao;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
|
||||
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
|
||||
pub use insights_dao::{InsightDao, SqliteInsightDao};
|
||||
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
|
||||
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
|
||||
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
|
||||
|
||||
pub trait UserDao {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::database::schema::{favorites, image_exif, photo_insights, users};
|
||||
use crate::database::schema::{favorites, image_exif, photo_insights, users, video_preview_clips};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -93,3 +93,24 @@ pub struct PhotoInsight {
|
||||
pub generated_at: i64,
|
||||
pub model_version: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = video_preview_clips)]
|
||||
pub struct InsertVideoPreviewClip {
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Queryable, Clone, Debug)]
|
||||
pub struct VideoPreviewClip {
|
||||
pub id: i32,
|
||||
pub file_path: String,
|
||||
pub status: String,
|
||||
pub duration_seconds: Option<f32>,
|
||||
pub file_size_bytes: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
183
src/database/preview_dao.rs
Normal file
183
src/database/preview_dao.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::database::models::{InsertVideoPreviewClip, VideoPreviewClip};
|
||||
use crate::database::{connect, DbError, DbErrorKind};
|
||||
use crate::otel::trace_db_call;
|
||||
|
||||
pub trait PreviewDao: Sync + Send {
|
||||
fn insert_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
fn update_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
duration: Option<f32>,
|
||||
size: Option<i32>,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
fn get_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
) -> Result<Option<VideoPreviewClip>, DbError>;
|
||||
|
||||
fn get_previews_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError>;
|
||||
|
||||
fn get_by_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
status_val: &str,
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError>;
|
||||
}
|
||||
|
||||
pub struct SqlitePreviewDao {
|
||||
connection: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl Default for SqlitePreviewDao {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqlitePreviewDao {
|
||||
pub fn new() -> Self {
|
||||
SqlitePreviewDao {
|
||||
connection: Arc::new(Mutex::new(connect())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewDao for SqlitePreviewDao {
|
||||
fn insert_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "insert", "insert_preview", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
diesel::insert_or_ignore_into(video_preview_clips)
|
||||
.values(InsertVideoPreviewClip {
|
||||
file_path: file_path_val.to_string(),
|
||||
status: status_val.to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||
}
|
||||
|
||||
fn update_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
status_val: &str,
|
||||
duration: Option<f32>,
|
||||
size: Option<i32>,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "update_preview_status", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
diesel::update(video_preview_clips.filter(file_path.eq(file_path_val)))
|
||||
.set((
|
||||
status.eq(status_val),
|
||||
duration_seconds.eq(duration),
|
||||
file_size_bytes.eq(size),
|
||||
error_message.eq(error),
|
||||
updated_at.eq(&now),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
|
||||
}
|
||||
|
||||
fn get_preview(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_path_val: &str,
|
||||
) -> Result<Option<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_preview", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
match video_preview_clips
|
||||
.filter(file_path.eq(file_path_val))
|
||||
.first::<VideoPreviewClip>(connection.deref_mut())
|
||||
{
|
||||
Ok(clip) => Ok(Some(clip)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!("Query error: {}", e)),
|
||||
}
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_previews_batch(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
file_paths: &[String],
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_previews_batch", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
if file_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
video_preview_clips
|
||||
.filter(file_path.eq_any(file_paths))
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
|
||||
fn get_by_status(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
status_val: &str,
|
||||
) -> Result<Vec<VideoPreviewClip>, DbError> {
|
||||
trace_db_call(context, "query", "get_previews_by_status", |_span| {
|
||||
use crate::database::schema::video_preview_clips::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
|
||||
|
||||
video_preview_clips
|
||||
.filter(status.eq(status_val))
|
||||
.load::<VideoPreviewClip>(connection.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,19 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
video_preview_clips (id) {
|
||||
id -> Integer,
|
||||
file_path -> Text,
|
||||
status -> Text,
|
||||
duration_seconds -> Nullable<Float>,
|
||||
file_size_bytes -> Nullable<Integer>,
|
||||
error_message -> Nullable<Text>,
|
||||
created_at -> Text,
|
||||
updated_at -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(tagged_photo -> tags (tag_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
@@ -167,4 +180,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
tagged_photo,
|
||||
tags,
|
||||
users,
|
||||
video_preview_clips,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user