diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 587560c..d20a6b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,9 +5,10 @@ pub mod strings; use ff::extract_streams; use media::{detect_media_type, is_media_file}; -use models::{StreamResult, StreamResultError}; +use models::{StreamResult, StreamResultError, File, FileCandidacy, BitrateData, BitrateFrame}; use strings::transform_filename; use std::path::Path; +use std::process::Command; use tracing::{debug, error, info, instrument, warn}; // detection, helpers moved to modules above @@ -132,12 +133,174 @@ fn has_streams(paths: Vec) -> Result, StreamResultErro results } +#[tauri::command] +#[instrument(skip(paths), fields(file_count = paths.len()))] +fn analyze_files(paths: Vec) -> Vec { + info!(file_count = paths.len(), "Analyzing files for candidacy"); + + paths + .into_iter() + .enumerate() + .map(|(index, path_str)| { + let path = Path::new(&path_str); + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Log full path only on first occurrence, then use truncated filename + if index == 0 { + debug!(full_path = %path_str, filename = %filename, "Processing first file"); + } else { + let truncated_name = transform_filename(&filename, 15); + debug!(filename = %truncated_name, "Processing file"); + } + + // Get file size + let size = std::fs::metadata(&path_str) + .map(|metadata| metadata.len()) + .unwrap_or(0) as u32; + + let truncated_name = transform_filename(&filename, 15); + debug!(filename = %truncated_name, size = size, "File metadata retrieved"); + + // Check if file exists + if !path.exists() { + let truncated_name = transform_filename(&filename, 15); + warn!(filename = %truncated_name, "File does not exist"); + return File { + filename, + size, + candidacy: FileCandidacy::Error { + reason: "File does not exist".to_string(), + }, + }; + } + + // Check if it's a file (not directory) + if !path.is_file() { + let truncated_name = transform_filename(&filename, 15); + warn!(filename = %truncated_name, "Path is not a file"); + return File { + filename, + size, + candidacy: FileCandidacy::Error { + reason: "Not a file (directory or other)".to_string(), + }, + }; + } + + // Detect media type using magic numbers and fallback to extensions + let media_type = detect_media_type(path); + debug!(filename = %truncated_name, media_type = ?media_type, "Media type detected"); + + // Check if it's a media file + if is_media_file(&media_type) { + info!(filename = %truncated_name, media_type = ?media_type, "Valid media file detected"); + File { + filename, + size, + candidacy: FileCandidacy::Success { + file_type: media_type, + }, + } + } else { + debug!(filename = %truncated_name, media_type = ?media_type, "Non-media file detected"); + File { + filename, + size, + candidacy: FileCandidacy::Error { + reason: format!("Not a media file (detected as {media_type:?})"), + }, + } + } + }) + .collect() +} + +#[tauri::command] +#[instrument(skip(path), fields(path = %path))] +fn extract_bitrate_data(path: String) -> Result { + info!(path = %path, "Extracting bitrate data from video file"); + + let path_obj = Path::new(&path); + let filename = path_obj + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Check if file exists + if !path_obj.exists() { + error!(filename = %filename, "File does not exist"); + return Err("File does not exist".to_string()); + } + + // Run ffprobe to get frame packet sizes + // -v quiet: suppress ffprobe info + // -select_streams v:0: only first video stream + // -show_entries frame=pkt_size: only show packet size + // -of csv=p=0: output as CSV without headers + info!(filename = %filename, "Running ffprobe to extract frame data"); + + let output = Command::new("ffprobe") + .args([ + "-v", "quiet", + "-select_streams", "v:0", + "-show_entries", "frame=pkt_size", + "-of", "csv=p=0", + &path + ]) + .output() + .map_err(|e| { + error!(error = %e, "Failed to execute ffprobe"); + format!("Failed to execute ffprobe: {e}") + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!(stderr = %stderr, "ffprobe command failed"); + return Err(format!("ffprobe failed: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + debug!(line_count = stdout.lines().count(), "Parsing ffprobe output"); + + let frames: Vec = stdout + .lines() + .enumerate() + .filter_map(|(index, line)| { + line.trim().parse::().ok().map(|packet_size| BitrateFrame { + frame_num: index as u32, + packet_size, + }) + }) + .collect(); + + if frames.is_empty() { + warn!(filename = %filename, "No frame data extracted"); + return Err("No frame data could be extracted from file".to_string()); + } + + info!( + filename = %filename, + frame_count = frames.len(), + "Successfully extracted bitrate data" + ); + + Ok(BitrateData { + id: filename, + frames, + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { info!("Initializing Tauri application"); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![has_streams]) + .invoke_handler(tauri::generate_handler![has_streams, analyze_files, extract_bitrate_data]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } \ No newline at end of file diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2800b05..b33f12b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -51,6 +51,39 @@ pub struct StreamResultError { pub error_type: String, } +// New types for simplified drop overlay +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +pub struct File { + pub filename: String, + pub size: u32, + pub candidacy: FileCandidacy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +pub enum FileCandidacy { + Success { + #[serde(rename = "type")] + file_type: MediaType, + }, + Error { + reason: String, + }, + Loading, +} + +// Bitrate visualization types +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +pub struct BitrateFrame { + pub frame_num: u32, + pub packet_size: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, TS)] +pub struct BitrateData { + pub id: String, + pub frames: Vec, +} + #[cfg(test)] mod tests { #[test] @@ -62,5 +95,9 @@ mod tests { StreamResult::export_all_to("../src/bindings").expect("Failed to export bindings"); StreamResultError::export_all_to("../src/bindings").expect("Failed to export bindings"); MediaType::export_all_to("../src/bindings").expect("Failed to export bindings"); + File::export_all_to("../src/bindings").expect("Failed to export bindings"); + FileCandidacy::export_all_to("../src/bindings").expect("Failed to export bindings"); + BitrateFrame::export_all_to("../src/bindings").expect("Failed to export bindings"); + BitrateData::export_all_to("../src/bindings").expect("Failed to export bindings"); } } diff --git a/src/App.tsx b/src/App.tsx index 47c052c..4051771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,45 @@ +import { useEffect, useState } from "react"; import { useDragDropPaths } from "@/hooks/useDragDropPaths"; import Graph from "@/components/graph"; import DropOverlay from "@/components/drop-overlay"; import type { Frame } from "@/types/graph"; +import { commands } from "@/bindings"; +import type { BitrateData } from "@/bindings"; function App() { - const data: Frame[] = []; - + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); const paths = useDragDropPaths(); + useEffect(() => { + if (paths.length === 0) { + return; + } + + // For minimal prototype, just process the first file + const firstPath = paths[0]; + setIsLoading(true); + + commands + .extractBitrateData(firstPath) + .then((bitrateData: BitrateData) => { + // Transform BitrateData to Nivo's Frame format + const frame: Frame = { + id: bitrateData.id, + data: bitrateData.frames.map((frame) => ({ + x: frame.frame_num, + y: Number(frame.packet_size), + })), + }; + setData([frame]); + setIsLoading(false); + }) + .catch((error) => { + console.error("Failed to extract bitrate data:", error); + setIsLoading(false); + }); + }, [paths]); + const graph = ; return ( @@ -17,6 +49,11 @@ function App() { style={{ "--wails-drop-target": "drop" } as React.CSSProperties} > + {isLoading && ( +
+ Extracting bitrate data... +
+ )} {graph} ); diff --git a/src/bindings.ts b/src/bindings.ts index 6a92700..67817a9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3,7 +3,11 @@ import type { StreamResult } from "@/bindings/StreamResult"; import type { StreamDetail } from "@/bindings/StreamDetail"; import type { StreamResultError } from "@/bindings/StreamResultError"; import type { MediaType } from "@/bindings/MediaType"; -export type { StreamResult, StreamDetail, StreamResultError, MediaType }; +import type { File } from "@/bindings/File"; +import type { FileCandidacy } from "@/bindings/FileCandidacy"; +import type { BitrateData } from "@/bindings/BitrateData"; +import type { BitrateFrame } from "@/bindings/BitrateFrame"; +export type { StreamResult, StreamDetail, StreamResultError, MediaType, File, FileCandidacy, BitrateData, BitrateFrame }; // Tauri invoke wrapper import { invoke } from "@tauri-apps/api/core"; @@ -21,5 +25,13 @@ export const commands = { if (e instanceof Error) throw e; else return { status: "error", error: e as any }; } + }, + + async analyzeFiles(paths: string[]): Promise { + return await invoke("analyze_files", { paths }); + }, + + async extractBitrateData(path: string): Promise { + return await invoke("extract_bitrate_data", { path }); } }; diff --git a/src/components/drop-overlay.tsx b/src/components/drop-overlay.tsx index d9f8db7..f17580f 100644 --- a/src/components/drop-overlay.tsx +++ b/src/components/drop-overlay.tsx @@ -1,7 +1,6 @@ -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { match, P } from "ts-pattern"; import { - CheckCircle, File as FileIcon, FileText, Film, @@ -11,38 +10,12 @@ import { XCircle, } from "lucide-react"; import { commands } from "@/bindings"; -import type { MediaType, StreamDetail } from "@/bindings"; +import type { File, FileCandidacy, MediaType } from "@/bindings"; type DropOverlayProps = { paths: string[]; }; -type State = - | { status: "hidden" } - | { status: "loading"; count: number } - | { - status: "ready"; - files: { - name: string; - key: string; - media_type: MediaType; - duration?: number | null; - size: number; - streams: StreamDetail[]; - }[]; - } - | { status: "error"; reason: string; filename?: string; error_type?: string }; - -type FileItemProps = { - filename: string; - media_type: MediaType; - duration?: number | null; - size: number; - streams: StreamDetail[]; - error?: string; - error_type?: string; -}; - const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 B"; const k = 1024; @@ -51,139 +24,76 @@ const formatFileSize = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; -const formatDuration = (seconds: number): string => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${secs - .toString() - .padStart(2, "0")}`; - } - return `${minutes}:${secs.toString().padStart(2, "0")}`; -}; - -const getFileIcon = ( - mediaType: MediaType, - error?: string, - errorType?: string, -) => { - // For non-media files, show a neutral icon instead of error icon - if (errorType === "not_media") { - switch (mediaType) { - case "Executable": - return ; - case "Archive": - return ; - case "Library": - return ; - case "Document": - return ; - default: - return ; - } - } - - if (error) { - return ; - } - - switch (mediaType) { - case "Audio": - return ; - case "Video": - return ; - case "Image": - return ; - case "Document": - return ; - case "Executable": - return ; - case "Archive": - return ; - case "Library": - return ; - default: - return ; - } -}; - -const getStreamInfo = ( - streams: StreamDetail[], - mediaType: MediaType, -): string => { - // For non-media files, return file type description - if (!["Audio", "Video", "Image"].includes(mediaType)) { - switch (mediaType) { - case "Executable": - return "Executable file"; - case "Archive": - return "Archive file"; - case "Library": - return "Library file"; - case "Document": - return "Document file"; - default: - return "Unknown file type"; - } - } - - // For media files, analyze streams - const videoStreams = streams.filter((s: any) => "Video" in s); - const audioStreams = streams.filter((s: any) => "Audio" in s); - const subtitleStreams = streams.filter((s: any) => "Subtitle" in s); - - const parts = [] as string[]; - if (videoStreams.length > 0) { - const video: any = videoStreams[0] as any; - if ("Video" in video) { - const width = (video as any).Video.width; - const height = (video as any).Video.height; - const codec = (video as any).Video.codec; - if (width && height) { - parts.push(`${width}x${height} ${codec}`); - } else { - parts.push(codec); +const getFileIcon = (candidacy: FileCandidacy): ReactNode => { + return match(candidacy) + .with("Loading", () => ( + + )) + .with({ Error: P._ }, () => ) + .with({ Success: { type: P.select() } }, (mediaType: MediaType) => { + switch (mediaType) { + case "Audio": + return ; + case "Video": + return ; + case "Image": + return ; + case "Document": + return ; + case "Executable": + return ; + case "Archive": + return ; + case "Library": + return ; + default: + return ; } - } - } - if (audioStreams.length > 0) { - const audio: any = audioStreams[0] as any; - if ("Audio" in audio) { - parts.push(`${(audio as any).Audio.codec} audio`); - } - } - if (subtitleStreams.length > 0) { - parts.push(`${subtitleStreams.length} subtitle(s)`); - } - - return parts.join(", "); + }) + .exhaustive(); }; -const Item = ({ - icon, - text, - subtitle, - status, -}: { - icon: ReactNode; - text: ReactNode; - subtitle?: ReactNode; - status?: "success" | "error" | "loading"; -}) => { - const statusColor = - status === "success" - ? "border-green-500" - : status === "error" - ? "border-red-500" - : status === "loading" - ? "border-blue-500" - : "border-neutral-600"; +const getStatusColor = (candidacy: FileCandidacy): string => { + return match(candidacy) + .with("Loading", () => "border-blue-500/50") + .with({ Error: P._ }, () => "border-red-500/50") + .with({ Success: P._ }, () => "border-green-500/50") + .exhaustive(); +}; + +const FileItem = ({ file }: { file: File }) => { + const icon = getFileIcon(file.candidacy); + const statusColor = getStatusColor(file.candidacy); + const fileSize = formatFileSize(file.size); + + const subtitle = match(file.candidacy) + .with("Loading", () => "Analyzing...") + .with({ Error: { reason: P.select() } }, (reason: string) => reason) + .with({ Success: { type: P.select() } }, (mediaType: MediaType) => { + switch (mediaType) { + case "Audio": + return "Audio file"; + case "Video": + return "Video file"; + case "Image": + return "Image file"; + case "Document": + return "Document file"; + case "Executable": + return "Executable file"; + case "Archive": + return "Archive file"; + case "Library": + return "Library file"; + default: + return "Unknown file type"; + } + }) + .exhaustive(); return (
{icon}
-
{text}
- {subtitle && ( -
- {subtitle} -
- )} +
+ {file.filename} +
+
+ {fileSize} • {subtitle} +
); }; -const FileItem = ({ - filename, - media_type, - duration, - size, - streams, - error, - error_type, -}: FileItemProps) => { - const icon = getFileIcon(media_type, error, error_type); - const fileSize = formatFileSize(size); - - let subtitle: ReactNode; - let status: "success" | "error" | "loading" | undefined; - - if (error) { - subtitle = error; - // For non-media files, show as neutral instead of error - status = error_type === "not_media" ? undefined : "error"; - } else { - const streamInfo = getStreamInfo(streams, media_type); - const durationStr = duration ? formatDuration(duration) : null; - const details = [streamInfo, durationStr, fileSize].filter( - Boolean, - ) as string[]; - subtitle = details.join(" • "); - status = "success"; - } - - return ( - - ); -}; - const DropOverlay = ({ paths }: DropOverlayProps) => { - const [state, setState] = useState({ status: "hidden" }); - const aborterRef = useRef(null); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (paths.length === 0) { - setState({ status: "hidden" }); + setFiles([]); + setIsLoading(false); return; } - setState({ status: "loading", count: paths.length }); + setIsLoading(true); + setFiles([]); - aborterRef.current = new AbortController(); - - commands.hasStreams(paths).then((result) => { - setState((_state) => { - return match(result) - .with({ status: "ok" }, (r) => ({ - status: "ready" as const, - files: r.data.map((item) => ({ - name: item.filename, - key: item.path, - media_type: item.media_type, - duration: item.duration, - size: Number(item.size), - streams: item.streams, - })), - })) - .with({ status: "error" }, (r) => { - if (r.error.filename) { - return { - status: "error" as const, - reason: r.error.reason, - filename: r.error.filename, - error_type: r.error.error_type, - }; - } - - return { - status: "error" as const, - reason: r.error.reason, - error_type: r.error.error_type, - }; - }) - .exhaustive(); - }); + // Initialize with loading state for all files + const loadingFiles: File[] = paths.map((path) => { + const filename = path.split(/[/\\]/).pop() || "unknown"; + return { + filename, + size: 0, + candidacy: "Loading" as const, + }; }); + setFiles(loadingFiles); + + // Analyze files + commands + .analyzeFiles(paths) + .then((analyzedFiles) => { + setFiles(analyzedFiles); + setIsLoading(false); + }) + .catch((error) => { + console.error("Failed to analyze files:", error); + // Set all files to error state + const errorFiles: File[] = paths.map((path) => { + const filename = path.split(/[/\\]/).pop() || "unknown"; + return { + filename, + size: 0, + candidacy: { + Error: { + reason: "Failed to analyze file", + }, + }, + }; + }); + setFiles(errorFiles); + setIsLoading(false); + }); }, [paths]); - if (state.status === "hidden") { + if (files.length === 0) { return null; } - const inner = match(state) - .with({ status: "loading" }, ({ count }) => ( -
- -
- Analyzing {count} file{count > 1 ? "s" : ""}... -
- {Array.from({ length: Math.min(count, 3) }).map((_, i) => ( - - } - text={ - - } - status="loading" - /> - ))} -
- )) - .with({ status: "ready" }, (r) => { - return ( -
-
- - Files Ready -
-
- {r.files.slice(0, 8).map((file) => ( - - ))} -
-
- ); - }) - .with({ status: "error", filename: P.string }, (r) => { - return ( -
-
- - Error -
- -
- ); - }) - .with({ status: "error" }, ({ reason }) => { - return ( -
-
- - Error -
- } - text={reason} - status="error" - /> -
- ); - }) - .exhaustive(); - return ( -
+
-
- {inner} +
+
+ {isLoading && ( +
+ + + Analyzing {files.length} file{files.length > 1 ? "s" : ""}... + +
+ )} +
+ {files.map((file, index) => ( + + ))} +
+
diff --git a/src/components/graph.tsx b/src/components/graph.tsx index 619dfa5..08cd042 100644 --- a/src/components/graph.tsx +++ b/src/components/graph.tsx @@ -51,11 +51,11 @@ const Graph = ({ data }: GraphProps) => ( fill: "#6e6a86", }, }} - axisBottom={{ legend: "transportation", legendOffset: 36 }} + axisBottom={{ legend: "Frame Number", legendOffset: 36 }} axisLeft={{ - legend: "count", + legend: "Packet Size", legendOffset: -40, - format: (v) => formatBytes(v * 1024 * 53), + format: (v) => formatBytes(v), }} pointSize={10} colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]} diff --git a/src/hooks/useDragDropPaths.ts b/src/hooks/useDragDropPaths.ts index 272d4a6..fc77bf3 100644 --- a/src/hooks/useDragDropPaths.ts +++ b/src/hooks/useDragDropPaths.ts @@ -7,14 +7,13 @@ export function useDragDropPaths(): string[] { useEffect(() => { const unlistenPromise = getCurrentWebview().onDragDropEvent( async ({ payload }) => { - if (payload.type === "enter") { + if (payload.type === "drop") { setPaths(payload.paths); - } else if (payload.type === "leave" || payload.type === "drop") { + } else if (payload.type === "leave") { setPaths([]); } }, ); - return () => { unlistenPromise.then((unlisten) => unlisten()); };