Add Gaussian Splat generation functionality

- Introduced a new `GaussianSplat` variant in the `JobOutput` enum to handle output from Gaussian splat jobs.
- Implemented `GenerateSplatAction` for initiating Gaussian splat generation, including input and output structures.
- Created `GaussianSplatJob` and its associated processing logic for batch image processing and splat generation.
- Added `GaussianSplatProcessor` to manage the processing of images into 3D Gaussian splats.
- Updated the media module to include new splat-related functionalities and integrated them into the existing workflow.
- Enhanced the interface components to support visualization and interaction with Gaussian splats, including a toggle for displaying splat overlays.
- Updated TypeScript types to accommodate new splat generation inputs and outputs, ensuring type safety across the application.
This commit is contained in:
Jamie Pine
2025-12-18 12:20:54 -08:00
parent 83809fadc3
commit 8abc966b84
11 changed files with 1014 additions and 138 deletions

View File

@@ -82,6 +82,13 @@ pub enum JobOutput {
error_count: usize,
},
/// Gaussian splat generation output
GaussianSplat {
total_processed: usize,
success_count: usize,
error_count: usize,
},
/// Generic output with custom data
#[specta(skip)]
Custom(serde_json::Value),
@@ -269,6 +276,17 @@ impl fmt::Display for JobOutput {
total_processed, success_count, error_count
)
}
Self::GaussianSplat {
total_processed,
success_count,
error_count,
} => {
write!(
f,
"Gaussian splat: {} processed ({} success, {} errors)",
total_processed, success_count, error_count
)
}
Self::Custom(_) => write!(f, "Custom output"),
}
}

View File

@@ -4,6 +4,7 @@
//! - Thumbnail generation
//! - OCR (text extraction from images/PDFs)
//! - Speech-to-text (audio/video transcription)
//! - Gaussian splat generation (3D view synthesis from images)
//! - Video transcoding
//! - Audio metadata extraction
//! - Image optimization
@@ -13,6 +14,7 @@ pub mod blurhash;
pub mod metadata_extractor;
pub mod ocr;
pub mod proxy;
pub mod splat;
#[cfg(feature = "ffmpeg")]
pub mod speech;
@@ -29,6 +31,7 @@ pub use metadata_extractor::{
};
pub use ocr::{OcrJob, OcrProcessor};
pub use proxy::{ProxyJob, ProxyProcessor};
pub use splat::{GaussianSplatJob, GaussianSplatProcessor};
#[cfg(feature = "ffmpeg")]
pub use speech::{SpeechToTextJob, SpeechToTextProcessor};

View File

@@ -0,0 +1,95 @@
//! Gaussian splat action handlers
use super::{
job::{GaussianSplatJob, GaussianSplatJobConfig},
processor::GaussianSplatProcessor,
};
use crate::{
context::CoreContext,
infra::action::{error::ActionError, LibraryAction},
ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct GenerateSplatInput {
pub entry_uuid: Uuid,
pub model_path: Option<String>, // Path to SHARP model checkpoint or None for auto-download
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct GenerateSplatOutput {
/// Job ID for tracking splat generation progress
pub job_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateSplatAction {
input: GenerateSplatInput,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct GaussianSplatJobOutput {
pub total_processed: usize,
pub success_count: usize,
pub error_count: usize,
}
impl GenerateSplatAction {
pub fn new(input: GenerateSplatInput) -> Self {
Self { input }
}
}
impl LibraryAction for GenerateSplatAction {
type Input = GenerateSplatInput;
type Output = GenerateSplatOutput;
fn from_input(input: GenerateSplatInput) -> Result<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
tracing::info!(
"Dispatching Gaussian splat job for entry: {}",
self.input.entry_uuid
);
// Create job config for single file
let job_config = super::job::GaussianSplatJobConfig {
location_id: None,
entry_uuid: Some(self.input.entry_uuid), // Single file mode
model_path: self.input.model_path,
reprocess: false,
};
// Create job
let job = super::job::GaussianSplatJob::new(job_config);
// Dispatch job
let job_handle = library
.jobs()
.dispatch(job)
.await
.map_err(|e| ActionError::Internal(format!("Failed to dispatch job: {}", e)))?;
tracing::info!("Gaussian splat job dispatched: {}", job_handle.id());
Ok(GenerateSplatOutput {
job_id: job_handle.id().to_string(),
})
}
fn action_kind(&self) -> &'static str {
"media.splat.generate"
}
}
crate::register_library_action!(GenerateSplatAction, "media.splat.generate");

View File

@@ -0,0 +1,346 @@
//! Gaussian splat generation job for batch image processing
use super::processor::GaussianSplatProcessor;
use crate::{
infra::{
db::entities::entry,
job::{prelude::*, traits::DynJob},
},
ops::indexing::processor::ProcessorEntry,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use tracing::{info, warn};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct GaussianSplatJobConfig {
/// Location ID to process (None = all entries in library)
pub location_id: Option<Uuid>,
/// Single entry UUID to process (for UI-triggered single file)
pub entry_uuid: Option<Uuid>,
/// Path to SHARP model checkpoint (None = auto-download)
pub model_path: Option<String>,
/// Reprocess files that already have splats
pub reprocess: bool,
}
impl Default for GaussianSplatJobConfig {
fn default() -> Self {
Self {
location_id: None,
entry_uuid: None,
model_path: None,
reprocess: false,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SplatJobState {
phase: SplatPhase,
entries: Vec<(i32, std::path::PathBuf, Option<String>)>,
processed: usize,
success_count: usize,
error_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
enum SplatPhase {
Discovery,
Processing,
Complete,
}
#[derive(Serialize, Deserialize)]
pub struct GaussianSplatJob {
config: GaussianSplatJobConfig,
state: SplatJobState,
}
impl GaussianSplatJob {
pub fn new(config: GaussianSplatJobConfig) -> Self {
Self {
config,
state: SplatJobState {
phase: SplatPhase::Discovery,
entries: Vec::new(),
processed: 0,
success_count: 0,
error_count: 0,
},
}
}
pub fn from_location(location_id: Uuid) -> Self {
Self::new(GaussianSplatJobConfig {
location_id: Some(location_id),
..Default::default()
})
}
}
impl Job for GaussianSplatJob {
const NAME: &'static str = "gaussian_splat";
const RESUMABLE: bool = true;
const DESCRIPTION: Option<&'static str> =
Some("Generate 3D Gaussian splats from images for view synthesis");
}
#[async_trait::async_trait]
impl JobHandler for GaussianSplatJob {
type Output = GaussianSplatJobOutput;
async fn run(&mut self, ctx: JobContext<'_>) -> JobResult<Self::Output> {
match self.state.phase {
SplatPhase::Discovery => {
ctx.log("Starting Gaussian splat discovery phase");
self.run_discovery(&ctx).await?;
self.state.phase = SplatPhase::Processing;
}
SplatPhase::Processing => {}
SplatPhase::Complete => {
return Ok(GaussianSplatJobOutput {
total_processed: self.state.processed,
success_count: self.state.success_count,
error_count: self.state.error_count,
});
}
}
ctx.log(format!(
"Gaussian splat processing {} images",
self.state.entries.len()
));
let processor = GaussianSplatProcessor::new(ctx.library_arc());
let processor = if let Some(ref model_path) = self.config.model_path {
processor.with_model_path(model_path.clone())
} else {
processor
};
let total = self.state.entries.len();
while self.state.processed < total {
ctx.check_interrupt().await?;
let (entry_id, path, mime_type) = &self.state.entries[self.state.processed];
// Load entry to get content_id
let entry_model = entry::Entity::find_by_id(*entry_id)
.one(ctx.library_db())
.await?
.ok_or_else(|| JobError::execution("Entry not found"))?;
let proc_entry = ProcessorEntry {
id: *entry_id,
uuid: entry_model.uuid,
path: path.clone(),
kind: crate::ops::indexing::state::EntryKind::File,
size: entry_model.size as u64,
content_id: entry_model.content_id,
mime_type: mime_type.clone(),
};
if !processor.should_process(&proc_entry) {
self.state.processed += 1;
continue;
}
// Report progress
ctx.progress(Progress::Indeterminate(format!(
"Generating splat for {}...",
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
)));
let result = processor.process(ctx.library_db(), &proc_entry).await;
match result {
Ok(result) if result.success => {
ctx.log(format!(
"Generated splat for {}: {} bytes",
path.display(),
result.bytes_processed
));
self.state.success_count += 1;
}
Ok(_) => {
warn!("Splat generation failed for {}", path.display());
self.state.error_count += 1;
}
Err(e) => {
ctx.log(format!(
"ERROR: Splat generation error for {}: {}",
path.display(),
e
));
self.state.error_count += 1;
}
}
self.state.processed += 1;
// Report progress with count
ctx.progress(Progress::Count {
current: self.state.processed,
total,
});
if self.state.processed % 5 == 0 {
ctx.checkpoint().await?;
}
}
self.state.phase = SplatPhase::Complete;
ctx.log(format!(
"Gaussian splat generation complete: {} success, {} errors",
self.state.success_count, self.state.error_count
));
Ok(GaussianSplatJobOutput {
total_processed: self.state.processed,
success_count: self.state.success_count,
error_count: self.state.error_count,
})
}
}
impl GaussianSplatJob {
async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> {
use crate::infra::db::entities::{content_identity, entry, mime_type};
ctx.log("Starting Gaussian splat discovery");
// Check if SHARP CLI is available
if !super::check_sharp_available().await.unwrap_or(false) {
return Err(JobError::execution(
"SHARP CLI not found. Please install ml-sharp (pip install -e /path/to/ml-sharp)",
));
}
ctx.log("SHARP CLI available");
let db = ctx.library_db();
// Check if this is single-file mode (from UI action)
if let Some(entry_uuid) = self.config.entry_uuid {
ctx.log(format!("Single file mode: processing entry {}", entry_uuid));
// Load the specific entry
let entry_model = entry::Entity::find()
.filter(entry::Column::Uuid.eq(entry_uuid))
.one(db)
.await?
.ok_or_else(|| JobError::execution("Entry not found"))?;
if let Some(content_id) = entry_model.content_id {
if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id)
.one(db)
.await
{
if let Some(mime_id) = ci.mime_type_id {
if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await
{
if super::is_splat_supported(&mime.mime_type) {
if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path(
db,
entry_model.id,
)
.await
{
self.state.entries.push((
entry_model.id,
path,
Some(mime.mime_type),
));
}
}
}
}
}
}
ctx.log(format!(
"Single file discovered: {} entries",
self.state.entries.len()
));
return Ok(());
}
// Batch mode - discover all eligible entries
let entries = entry::Entity::find()
.filter(entry::Column::ContentId.is_not_null())
.all(db)
.await?;
ctx.log(format!("Found {} entries with content", entries.len()));
for entry_model in entries {
if let Some(content_id) = entry_model.content_id {
if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id)
.one(db)
.await
{
if let Some(mime_id) = ci.mime_type_id {
if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await
{
if super::is_splat_supported(&mime.mime_type) {
if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path(
db,
entry_model.id,
)
.await
{
self.state.entries.push((
entry_model.id,
path,
Some(mime.mime_type),
));
}
}
}
}
}
}
}
ctx.log(format!(
"Discovery complete: {} image files",
self.state.entries.len()
));
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct GaussianSplatJobOutput {
pub total_processed: usize,
pub success_count: usize,
pub error_count: usize,
}
impl From<GaussianSplatJobOutput> for JobOutput {
fn from(output: GaussianSplatJobOutput) -> Self {
JobOutput::GaussianSplat {
total_processed: output.total_processed,
success_count: output.success_count,
error_count: output.error_count,
}
}
}
impl DynJob for GaussianSplatJob {
fn job_name(&self) -> &'static str {
"Gaussian Splat Generation"
}
}
impl From<GaussianSplatJob> for Box<dyn DynJob> {
fn from(job: GaussianSplatJob) -> Self {
Box::new(job)
}
}

View File

@@ -0,0 +1,99 @@
//! Gaussian Splat generation system
//!
//! Generates 3D Gaussian splats from images using Apple's SHARP model.
//! Generates .ply sidecar files for photorealistic view synthesis.
pub mod action;
pub mod job;
pub mod processor;
pub use action::{GenerateSplatAction, GenerateSplatInput, GenerateSplatOutput};
pub use job::{GaussianSplatJob, GaussianSplatJobConfig};
pub use processor::GaussianSplatProcessor;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
/// Generate a 3D Gaussian splat from an image using SHARP
///
/// This calls the `sharp` CLI tool as a subprocess.
/// The sharp tool must be installed (e.g., via `pip install -r requirements.txt` in ml-sharp repo)
///
/// # Arguments
/// * `source_path` - Path to the input image
/// * `output_dir` - Directory where the .ply file will be generated
/// * `model_path` - Optional path to the SHARP model checkpoint
///
/// # Returns
/// Path to the generated .ply file
pub async fn generate_splat_from_image(
source_path: &Path,
output_dir: &Path,
model_path: Option<&Path>,
) -> Result<PathBuf> {
use tokio::process::Command;
// Ensure output directory exists
tokio::fs::create_dir_all(output_dir).await?;
// Build command
let mut cmd = Command::new("sharp");
cmd.arg("predict")
.arg("-i")
.arg(source_path)
.arg("-o")
.arg(output_dir);
// Add model path if provided
if let Some(model) = model_path {
cmd.arg("-c").arg(model);
}
// Execute
let output = cmd
.output()
.await
.context("Failed to execute 'sharp' command. Is it installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("SHARP failed: {}", stderr);
}
// The output file will be named based on input filename with .ply extension
let ply_filename = source_path
.file_stem()
.context("Invalid source filename")?
.to_str()
.context("Non-UTF8 filename")?;
let ply_path = output_dir.join(format!("{}.ply", ply_filename));
if !ply_path.exists() {
anyhow::bail!(
"SHARP did not generate expected output file: {:?}",
ply_path
);
}
Ok(ply_path)
}
/// Check if SHARP CLI is available in PATH
pub async fn check_sharp_available() -> Result<bool> {
let output = tokio::process::Command::new("sharp")
.arg("--help")
.output()
.await;
Ok(output.is_ok())
}
/// Check if an image type is supported for splat generation
pub fn is_splat_supported(mime_type: &str) -> bool {
// SHARP supports common image formats
matches!(
mime_type,
"image/jpeg" | "image/png" | "image/webp" | "image/bmp" | "image/tiff"
)
}

View File

@@ -0,0 +1,167 @@
//! Gaussian splat processor - generates 3D splats from images
use crate::library::Library;
use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult};
use crate::ops::indexing::state::EntryKind;
use crate::ops::sidecar::types::{SidecarFormat, SidecarKind, SidecarVariant};
use anyhow::Result;
use serde_json::Value;
use std::sync::Arc;
use tracing::{debug, warn};
pub struct GaussianSplatProcessor {
library: Arc<Library>,
model_path: Option<String>,
}
impl GaussianSplatProcessor {
pub fn new(library: Arc<Library>) -> Self {
Self {
library,
model_path: None,
}
}
pub fn with_model_path(mut self, path: String) -> Self {
self.model_path = Some(path);
self
}
pub fn with_settings(mut self, settings: &Value) -> Result<Self> {
if let Some(path) = settings.get("model_path").and_then(|v| v.as_str()) {
self.model_path = Some(path.to_string());
}
Ok(self)
}
pub fn should_process(&self, entry: &ProcessorEntry) -> bool {
if !matches!(entry.kind, EntryKind::File) {
return false;
}
if entry.content_id.is_none() {
return false;
}
entry
.mime_type
.as_ref()
.map_or(false, |m| super::is_splat_supported(m))
}
pub async fn process(
&self,
db: &sea_orm::DatabaseConnection,
entry: &ProcessorEntry,
) -> Result<ProcessorResult> {
let content_uuid = if let Some(content_id) = entry.content_id {
use crate::infra::db::entities::content_identity;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
let ci = content_identity::Entity::find()
.filter(content_identity::Column::Id.eq(content_id))
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?;
ci.uuid
.ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))?
} else {
return Ok(ProcessorResult::failure(
"Entry has no content_id".to_string(),
));
};
debug!("→ Generating Gaussian splat for: {}", entry.path.display());
// Get sidecar manager
let sidecar_manager = self
.library
.core_context()
.get_sidecar_manager()
.await
.ok_or_else(|| anyhow::anyhow!("SidecarManager not available"))?;
// Check if splat already exists
if sidecar_manager
.exists(
&self.library.id(),
&content_uuid,
&SidecarKind::GaussianSplat,
&SidecarVariant::new("ply"),
&SidecarFormat::Ply,
)
.await
.unwrap_or(false)
{
debug!("Gaussian splat already exists for {}", content_uuid);
return Ok(ProcessorResult::success(0, 0));
}
// Compute sidecar path
let sidecar_path = sidecar_manager
.compute_path(
&self.library.id(),
&content_uuid,
&SidecarKind::GaussianSplat,
&SidecarVariant::new("ply"),
&SidecarFormat::Ply,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to compute path: {}", e))?;
// Create temporary output directory
let temp_dir = std::env::temp_dir().join(format!("sd_splat_{}", content_uuid));
tokio::fs::create_dir_all(&temp_dir).await?;
// Generate splat using SHARP
let model_path_ref = self.model_path.as_ref().map(|s| std::path::Path::new(s));
let ply_path = super::generate_splat_from_image(&entry.path, &temp_dir, model_path_ref)
.await
.map_err(|e| anyhow::anyhow!("Failed to generate Gaussian splat: {}", e))?;
// Read generated PLY file
let ply_data = tokio::fs::read(&ply_path).await?;
let ply_size = ply_data.len();
debug!("Generated splat: {} bytes", ply_size);
// Ensure sidecar directory exists
if let Some(parent) = sidecar_path.absolute_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Copy PLY to sidecar location
tokio::fs::copy(&ply_path, &sidecar_path.absolute_path).await?;
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
debug!(
"✓ Generated Gaussian splat: {} ({} bytes)",
sidecar_path.relative_path.display(),
ply_size
);
// Register sidecar in database
sidecar_manager
.record_sidecar(
&self.library,
&content_uuid,
&SidecarKind::GaussianSplat,
&SidecarVariant::new("ply"),
&SidecarFormat::Ply,
ply_size as u64,
None,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to record sidecar: {}", e))?;
Ok(ProcessorResult::success(1, ply_size as u64))
}
pub fn name(&self) -> &'static str {
"gaussian_splat"
}
}

View File

@@ -11,6 +11,7 @@ pub enum SidecarKind {
Embeddings,
Ocr,
Transcript,
GaussianSplat,
}
impl SidecarKind {
@@ -22,6 +23,7 @@ impl SidecarKind {
Self::Embeddings => "embeddings",
Self::Ocr => "ocr",
Self::Transcript => "transcript",
Self::GaussianSplat => "gaussian_splat",
}
}
@@ -33,6 +35,7 @@ impl SidecarKind {
Self::Embeddings => "embeddings",
Self::Ocr => "ocr",
Self::Transcript => "transcript",
Self::GaussianSplat => "gaussian_splats",
}
}
}
@@ -54,6 +57,7 @@ impl TryFrom<&str> for SidecarKind {
"embeddings" => Ok(Self::Embeddings),
"ocr" => Ok(Self::Ocr),
"transcript" => Ok(Self::Transcript),
"gaussian_splat" => Ok(Self::GaussianSplat),
_ => Err(format!("Invalid sidecar kind: {}", value)),
}
}
@@ -98,6 +102,7 @@ impl From<String> for SidecarVariant {
/// - Json: Text-based structured data (OCR, transcripts)
/// - MessagePack: Binary structured data (embeddings, vectors)
/// - Text: Plain text extractions
/// - Ply: 3D model format for Gaussian splats
///
/// MessagePack is preferred for embeddings because:
/// - 6x smaller than JSON (1.7KB vs 10KB per 384-dim vector)
@@ -112,6 +117,7 @@ pub enum SidecarFormat {
Json,
MessagePack,
Text,
Ply,
}
impl SidecarFormat {
@@ -122,6 +128,7 @@ impl SidecarFormat {
Self::Json => "json",
Self::MessagePack => "msgpack",
Self::Text => "txt",
Self::Ply => "ply",
}
}
@@ -132,6 +139,7 @@ impl SidecarFormat {
Self::Json => "json",
Self::MessagePack => "messagepack",
Self::Text => "text",
Self::Ply => "ply",
}
}
}
@@ -152,6 +160,7 @@ impl TryFrom<&str> for SidecarFormat {
"json" => Ok(Self::Json),
"msgpack" | "messagepack" => Ok(Self::MessagePack),
"text" | "txt" => Ok(Self::Text),
"ply" => Ok(Self::Ply),
_ => Err(format!("Invalid sidecar format: {}", value)),
}
}

View File

@@ -8,6 +8,7 @@ import {
MagnifyingGlassPlus,
MagnifyingGlassMinus,
ArrowCounterClockwise,
Cube,
} from "@phosphor-icons/react";
import { VideoPlayer } from "./VideoPlayer";
import { AudioPlayer } from "./AudioPlayer";
@@ -28,12 +29,29 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
const [originalLoaded, setOriginalLoaded] = useState(false);
const [originalUrl, setOriginalUrl] = useState<string | null>(null);
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const [showSplat, setShowSplat] = useState(false);
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } =
useZoomPan(containerRef);
// Get a stable identifier for the image file itself
const imageFileId = file.content_identity?.uuid || file.id;
// Check if Gaussian splat sidecar exists and get URL
const splatSidecar = file.sidecars?.find(
(s) => s.kind === "gaussian_splat" && s.format === "ply"
);
const hasSplat = !!splatSidecar;
// Build sidecar URL for the splat
const splatUrl = hasSplat && file.content_identity?.uuid
? buildSidecarUrl(
file.content_identity.uuid,
splatSidecar!.kind,
splatSidecar!.variant,
splatSidecar!.format,
)
: null;
// Notify parent of zoom state changes
useEffect(() => {
onZoomChange?.(isZoomed);
@@ -44,6 +62,7 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
setShouldLoadOriginal(false);
setOriginalLoaded(false);
setOriginalUrl(null);
setShowSplat(false); // Reset splat view when file changes
const timer = setTimeout(() => {
setShouldLoadOriginal(true);
@@ -107,11 +126,54 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
const thumbnailUrl = getHighestResThumbnail();
// Render splat view separately (not overlayed)
if (showSplat && hasSplat && splatUrl) {
return (
<>
{/* Splat Toggle */}
<div className="absolute top-4 left-4 z-10">
<button
onClick={() => setShowSplat(false)}
className="rounded-lg p-2 bg-accent text-white backdrop-blur-xl transition-colors hover:bg-accent/90"
title="Show Image"
>
<Cube size={20} weight="bold" />
</button>
</div>
{/* Splat Viewer - matches direct .ply rendering structure */}
<Suspense
fallback={
<div className="w-full h-full flex items-center justify-center">
<FileComponent.Thumb file={file} size={200} />
</div>
}
>
<MeshViewer file={file} splatUrl={splatUrl} />
</Suspense>
</>
);
}
// Render image view with zoom/pan
return (
<div
ref={containerRef}
className={`relative w-full h-full flex items-center justify-center ${isZoomed ? "overflow-visible" : "overflow-hidden"}`}
>
{/* Splat Toggle (top-left) */}
{hasSplat && (
<div className="absolute top-4 left-4 z-10">
<button
onClick={() => setShowSplat(true)}
className="rounded-lg p-2 bg-app-box/80 text-ink backdrop-blur-xl transition-colors hover:bg-app-hover"
title="Show 3D Splat"
>
<Cube size={20} weight="bold" />
</button>
</div>
)}
{/* Zoom Controls */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
<button

View File

@@ -13,6 +13,7 @@ import * as THREE from "three";
interface MeshViewerProps {
file: File;
onZoomChange?: (isZoomed: boolean) => void;
splatUrl?: string | null; // Optional URL to Gaussian splat sidecar
}
interface MeshSceneProps {
@@ -172,7 +173,7 @@ function GaussianSplatViewer({
);
}
export function MeshViewer({ file, onZoomChange }: MeshViewerProps) {
export function MeshViewer({ file, onZoomChange, splatUrl }: MeshViewerProps) {
const platform = usePlatform();
const [meshUrl, setMeshUrl] = useState<string | null>(null);
const [isGaussianSplat, setIsGaussianSplat] = useState(false);
@@ -192,9 +193,17 @@ export function MeshViewer({ file, onZoomChange }: MeshViewerProps) {
}, 50);
return () => clearTimeout(timer);
}, [fileId]);
}, [fileId, splatUrl]);
useEffect(() => {
// If splatUrl is provided, use it directly (it's a Gaussian splat sidecar)
if (splatUrl) {
setMeshUrl(splatUrl);
setIsGaussianSplat(true);
setLoading(false);
return;
}
if (!shouldLoad || !platform.convertFileSrc) {
return;
}
@@ -211,6 +220,11 @@ export function MeshViewer({ file, onZoomChange }: MeshViewerProps) {
const url = platform.convertFileSrc(physicalPath);
setMeshUrl(url);
// Only run detection if not using splatUrl (splatUrl is already known to be a Gaussian splat)
if (splatUrl) {
return;
}
// Create an AbortController to cancel the detection fetch if component unmounts
const abortController = new AbortController();
@@ -254,7 +268,7 @@ export function MeshViewer({ file, onZoomChange }: MeshViewerProps) {
return () => {
abortController.abort();
};
}, [shouldLoad, fileId, file.sd_path, platform]);
}, [shouldLoad, fileId, file.sd_path, platform, splatUrl]);
if (!meshUrl || loading) {
return (

View File

@@ -21,6 +21,7 @@ import {
Trash,
FilmStrip,
VideoCamera,
Cube,
} from "@phosphor-icons/react";
import { useState } from "react";
import { getContentKind } from "../components/Explorer/utils";
@@ -120,6 +121,7 @@ function OverviewTab({ file }: { file: File }) {
// AI Processing mutations
const extractText = useLibraryMutation("media.ocr.extract");
const transcribeAudio = useLibraryMutation("media.speech.transcribe");
const generateSplat = useLibraryMutation("media.splat.generate");
const regenerateThumbnail = useLibraryMutation(
"media.thumbnail.regenerate",
);
@@ -463,6 +465,52 @@ function OverviewTab({ file }: { file: File }) {
</button>
)}
{/* Gaussian Splat for images */}
{isImage && (
<button
onClick={() => {
console.log(
"Generate splat clicked for file:",
file.id,
);
generateSplat.mutate(
{
entry_uuid: file.id,
model_path: null,
},
{
onSuccess: (data) => {
console.log(
"Splat generation success:",
data,
);
},
onError: (error) => {
console.error(
"Splat generation error:",
error,
);
},
},
);
}}
disabled={generateSplat.isPending}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-app-box hover:bg-app-hover border border-app-line",
generateSplat.isPending &&
"opacity-50 cursor-not-allowed",
)}
>
<Cube size={4} weight="bold" />
<span>
{generateSplat.isPending
? "Generating..."
: "Generate 3D Splat"}
</span>
</button>
)}
{/* Speech-to-text for audio/video */}
{(isVideo || isAudio) && (
<button

View File

@@ -1072,6 +1072,14 @@ variants: string[];
*/
encoding_time_secs: number };
export type GenerateSplatInput = { entry_uuid: string; model_path: string | null };
export type GenerateSplatOutput = {
/**
* Job ID for tracking splat generation progress
*/
job_id: string };
/**
* Generate thumbstrip for a single video file
*/
@@ -1633,7 +1641,11 @@ export type JobOutput =
/**
* Speech-to-text transcription output
*/
{ type: "SpeechToText"; data: { total_processed: number; success_count: number; error_count: number } };
{ type: "SpeechToText"; data: { total_processed: number; success_count: number; error_count: number } } |
/**
* Gaussian splat generation output
*/
{ type: "GaussianSplat"; data: { total_processed: number; success_count: number; error_count: number } };
export type JobPauseInput = { job_id: string };
@@ -3050,6 +3062,7 @@ export type Sidecar = { id: number; content_uuid: string; kind: string; variant:
* - Json: Text-based structured data (OCR, transcripts)
* - MessagePack: Binary structured data (embeddings, vectors)
* - Text: Plain text extractions
* - Ply: 3D model format for Gaussian splats
*
* MessagePack is preferred for embeddings because:
* - 6x smaller than JSON (1.7KB vs 10KB per 384-dim vector)
@@ -3057,9 +3070,9 @@ export type Sidecar = { id: number; content_uuid: string; kind: string; variant:
* - Already used in Spacedrive (job serialization)
* - Enables sub-30ms semantic search on 1M+ files
*/
export type SidecarFormat = "webp" | "mp_4" | "json" | "message_pack" | "text";
export type SidecarFormat = "webp" | "mp_4" | "json" | "message_pack" | "text" | "ply";
export type SidecarKind = "thumb" | "thumbstrip" | "proxy" | "embeddings" | "ocr" | "transcript";
export type SidecarKind = "thumb" | "thumbstrip" | "proxy" | "embeddings" | "ocr" | "transcript" | "gaussian_splat";
export type SidecarVariant = string;
@@ -4000,209 +4013,211 @@ success: boolean };
// ===== API Type Unions =====
export type CoreAction =
{ type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
{ type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
| { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
| { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput }
| { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput }
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
| { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput }
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput }
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
| { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
;
export type LibraryAction =
{ type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
{ type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
;
export type CoreQuery =
{ type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
{ type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
| { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
| { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput }
| { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput }
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
| { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
| { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
| { type: 'core.status'; input: Empty; output: CoreStatus }
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
;
export type LibraryQuery =
{ type: 'jobs.list'; input: JobListInput; output: JobListOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
{ type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'files.by_id'; input: FileByIdQuery; output: File }
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
| { type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
;
// ===== Wire Method Mappings =====
export const WIRE_METHODS = {
coreActions: {
'network.pair.cancel': 'action:network.pair.cancel.input',
'libraries.create': 'action:libraries.create.input',
'network.pair.join': 'action:network.pair.join.input',
'libraries.delete': 'action:libraries.delete.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'network.sync_setup': 'action:network.sync_setup.input',
'models.whisper.delete': 'action:models.whisper.delete.input',
'models.whisper.download': 'action:models.whisper.download.input',
'network.start': 'action:network.start.input',
'libraries.create': 'action:libraries.create.input',
'network.pair.generate': 'action:network.pair.generate.input',
'network.device.revoke': 'action:network.device.revoke.input',
'core.reset': 'action:core.reset.input',
'network.pair.cancel': 'action:network.pair.cancel.input',
'network.pair.join': 'action:network.pair.join.input',
'libraries.open': 'action:libraries.open.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'core.reset': 'action:core.reset.input',
'network.pair.generate': 'action:network.pair.generate.input',
'network.start': 'action:network.start.input',
'network.stop': 'action:network.stop.input',
'network.sync_setup': 'action:network.sync_setup.input',
'network.device.revoke': 'action:network.device.revoke.input',
},
libraryActions: {
'jobs.cancel': 'action:jobs.cancel.input',
'media.ocr.extract': 'action:media.ocr.extract.input',
'locations.rescan': 'action:locations.rescan.input',
'files.copy': 'action:files.copy.input',
'spaces.delete_group': 'action:spaces.delete_group.input',
'jobs.pause': 'action:jobs.pause.input',
'indexing.verify': 'action:indexing.verify.input',
'spaces.update': 'action:spaces.update.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'spaces.add_group': 'action:spaces.add_group.input',
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
'indexing.start': 'action:indexing.start.input',
'locations.remove': 'action:locations.remove.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'tags.apply': 'action:tags.apply.input',
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
'media.thumbnail': 'action:media.thumbnail.input',
'volumes.untrack': 'action:volumes.untrack.input',
'spaces.add_item': 'action:spaces.add_item.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'libraries.export': 'action:libraries.export.input',
'spaces.update_group': 'action:spaces.update_group.input',
'jobs.resume': 'action:jobs.resume.input',
'volumes.track': 'action:volumes.track.input',
'volumes.index': 'action:volumes.index.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'libraries.rename': 'action:libraries.rename.input',
'files.delete': 'action:files.delete.input',
'volumes.refresh': 'action:volumes.refresh.input',
'locations.triggerJob': 'action:locations.triggerJob.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'locations.add': 'action:locations.add.input',
'locations.import': 'action:locations.import.input',
'locations.update': 'action:locations.update.input',
'tags.apply': 'action:tags.apply.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'spaces.create': 'action:spaces.create.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'libraries.rename': 'action:libraries.rename.input',
'locations.export': 'action:locations.export.input',
'spaces.update': 'action:spaces.update.input',
'spaces.delete_group': 'action:spaces.delete_group.input',
'spaces.add_item': 'action:spaces.add_item.input',
'locations.triggerJob': 'action:locations.triggerJob.input',
'indexing.start': 'action:indexing.start.input',
'spaces.reorder_items': 'action:spaces.reorder_items.input',
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
'volumes.index': 'action:volumes.index.input',
'jobs.pause': 'action:jobs.pause.input',
'locations.update': 'action:locations.update.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'files.copy': 'action:files.copy.input',
'tags.create': 'action:tags.create.input',
'media.splat.generate': 'action:media.splat.generate.input',
'indexing.verify': 'action:indexing.verify.input',
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
'volumes.refresh': 'action:volumes.refresh.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'libraries.export': 'action:libraries.export.input',
'locations.import': 'action:locations.import.input',
'locations.rescan': 'action:locations.rescan.input',
'spaces.update_group': 'action:spaces.update_group.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'volumes.track': 'action:volumes.track.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'locations.remove': 'action:locations.remove.input',
'locations.add': 'action:locations.add.input',
'jobs.resume': 'action:jobs.resume.input',
'media.ocr.extract': 'action:media.ocr.extract.input',
'jobs.cancel': 'action:jobs.cancel.input',
'spaces.add_group': 'action:spaces.add_group.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'spaces.delete': 'action:spaces.delete.input',
'locations.export': 'action:locations.export.input',
'files.delete': 'action:files.delete.input',
},
coreQueries: {
'models.whisper.list': 'query:models.whisper.list',
'network.status': 'query:network.status',
'network.sync_setup.discover': 'query:network.sync_setup.discover',
'core.events.list': 'query:core.events.list',
'network.devices.list': 'query:network.devices.list',
'network.pair.status': 'query:network.pair.status',
'core.ephemeral_status': 'query:core.ephemeral_status',
'jobs.remote.all_devices': 'query:jobs.remote.all_devices',
'jobs.remote.for_device': 'query:jobs.remote.for_device',
'core.events.list': 'query:core.events.list',
'network.sync_setup.discover': 'query:network.sync_setup.discover',
'core.ephemeral_status': 'query:core.ephemeral_status',
'network.pair.status': 'query:network.pair.status',
'network.status': 'query:network.status',
'network.devices.list': 'query:network.devices.list',
'libraries.list': 'query:libraries.list',
'models.whisper.list': 'query:models.whisper.list',
'core.status': 'query:core.status',
'libraries.list': 'query:libraries.list',
},
libraryQueries: {
'jobs.list': 'query:jobs.list',
'files.directory_listing': 'query:files.directory_listing',
'sync.eventLog': 'query:sync.eventLog',
'sync.activity': 'query:sync.activity',
'locations.suggested': 'query:locations.suggested',
'search.files': 'query:search.files',
'test.ping': 'query:test.ping',
'spaces.list': 'query:spaces.list',
'locations.validate_path': 'query:locations.validate_path',
'files.media_listing': 'query:files.media_listing',
'volumes.list': 'query:volumes.list',
'spaces.get': 'query:spaces.get',
'sync.activity': 'query:sync.activity',
'jobs.info': 'query:jobs.info',
'files.by_id': 'query:files.by_id',
'tags.search': 'query:tags.search',
'libraries.info': 'query:libraries.info',
'jobs.active': 'query:jobs.active',
'sync.eventLog': 'query:sync.eventLog',
'test.ping': 'query:test.ping',
'locations.list': 'query:locations.list',
'files.directory_listing': 'query:files.directory_listing',
'search.files': 'query:search.files',
'files.media_listing': 'query:files.media_listing',
'libraries.info': 'query:libraries.info',
'sync.metrics': 'query:sync.metrics',
'spaces.get_layout': 'query:spaces.get_layout',
'jobs.info': 'query:jobs.info',
'locations.list': 'query:locations.list',
'locations.suggested': 'query:locations.suggested',
'devices.list': 'query:devices.list',
'files.by_path': 'query:files.by_path',
'spaces.list': 'query:spaces.list',
'locations.validate_path': 'query:locations.validate_path',
'files.unique_to_location': 'query:files.unique_to_location',
'jobs.list': 'query:jobs.list',
'files.by_path': 'query:files.by_path',
},
} as const;