From e797b02e65d72bb57f7f3f309bfbf960e4bb2731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Vasconcellos?= Date: Wed, 8 May 2024 23:20:28 -0300 Subject: [PATCH] Media metadata extraction & Thumbnailer rework (#2285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial ffprobe commit * Working slim down version ffprobe * Auto format ffprobe and deps source * Remove show_pixel_formats logic - Fix do_bitexact incorrect check in main after last changes - Fix some clangd warning * Remove show_* and print_format options and their respective logic * Rework ffprobe into simple_ffprobe - Simplify ffprobe logic into a simple program that gather and print a media file metadata * Reduce the amount of ffmpeg log messages while generating thumbnails * Fix completly wrong comments * mend * Start modeling ffmpeg extracted metadata on schema - Start porting ffprobe code to rust - Rename some references to media_data to exif_data * Finish modeling media info data - Add MediaProgram, MediaStream, MediaCodec, MediaVideoProps, MediaAudioProps, MediaSubtitleProps to Schema - Fix simple_ffproble to use its custom print_codec, instead of ffmpeg's impl * Add relation between MediaInfo and FilePath - Remove shared properties from MediaInfo and related structs - Implement Iterator for FFmpegDict * Fix and update schema * Data models and start populating MediaInfo in rust * Finish populating media info, chapters and program * Improve FFmpegFormatContext data raw pointer access - Implement stream data gathering * Impl FFmpegCodecContext, retrieve codec information - Improve some unsafe pointer uses - Impl from FFmpegFormatContext to MediaInfo conversion * Fix FFmpegDict Drop * Fix some crago warnings * Impl retrieval of video props - Fix C char* to Rust String convertion * Impl retrieval of audio and subtitle props - Fill props for MediaCodec * Remove simple_ffprobe now that the Rust impl is done * Fix schema to match actually retrieved media info - Fix import some FFmpeg constants instead of directly using values * Rework movie_decoder - Re-implement create_scale_string and add support anamorphic video - Improve C pointer access for FFmpegFormatContext and FFmpegCodecContext - Use newer FFmpeg abstractions in movie_decoder * Fix incorrect props when initializing MovieDecoder * Remove unecessary lifetimes * Added more native wrappers for some FFmpeg native objects used in movie_decoder * Remove FFmpegPacket - Some more improvements to movie_decoder * WIP * Some small fixes * More fixes Rename movie_decoder to frame_decoder Remove more references to film_strips * fmt * Fix duplicate migration for job error changes * fix rebase * Solving segfaults, fuck C lang Co-authored-by: Vítor Vasconcellos * Update rust to version 1.77 - Pin rust version with rust-toolchain.toml - Change from dtolnay/rust-toolchain to IronCoreLabs/rust-toolchain for rust-toolchain support - Remove unused function and imports - Replace most CString uses with new c literal string * More segfault solving and other minor fixes Co-authored-by: Vítor Vasconcellos * Fix ffmpeg rotation filter breaking portrait video thumbnails #2150 - Plus some other misc fixes * Auto format * Retrieve video/audio metadata on frontend * Auto format * First draft on ffmpeg data save on db Co-authored-by: Vítor Vasconcellos * Fix some incorrect changes to prisma schema * Some fixes for the FFmpegData schema - Expand logic to save FFmpegData to db * A ton of things Co-authored-by: Vítor Vasconcellos * Integrating ffmpeg media data in jobs and API * Rspc can't BigInt * 🙄 * Add initial ffmpeg metadata entries to Inspector - Fix ephemeral metadata api to match the files metadata api call * Fix Inspector not showing ffmpeg metadata * Add bitrate, start time and chapters video metadata to Inspector - Fix backend BigInt conversion incorrectly using i32 instead of u32 - Change FFmpegFormatContext/FFmpegMetaData bit_rate to i64 - Rename byteSize to humanizeSize - Expand humanizeSize logic to allow handling bits and Binary units - Move capitalize to @sd/client utils * Solving some issues * Fix ffmpeg probe getting incorrect stream id and breaking database unique constraint - Fix humanizeSize breaking when receiving floating numbers - Fix incorrect equality in StatCard - Fix unhandled error in Dialog when trying to remove an unknown dialog * fmt * small improvements - Remove some unecessary recursion_limit directive - Remove unused app_image releated functions - Fix metadata query enabled flag * Add migration for ffmpeg media data * Fix cypress test * Requested changes * Implement feedback - Update locale keys for all languages - Add pnpm command to update all language keys * Fix thumb reactivity in non indexed locations --------- Co-authored-by: Ericson Soares Co-authored-by: Vítor Vasconcellos --- .cspell/project_words.txt | 4 + .github/actions/setup-rust/action.yaml | 3 +- .vscode/launch.json | 3 +- .vscode/tasks.json | 3 +- CONTRIBUTING.md | 2 +- Cargo.lock | 13 +- Cargo.toml | 23 +- apps/desktop/crates/linux/Cargo.toml | 2 +- apps/desktop/crates/linux/src/app_info.rs | 21 +- apps/desktop/crates/linux/src/env.rs | 37 +- apps/desktop/crates/windows/Cargo.toml | 2 +- .../modules/sd-core/android/crate/Cargo.toml | 2 +- apps/mobile/modules/sd-core/core/Cargo.toml | 2 +- .../modules/sd-core/ios/crate/Cargo.toml | 2 +- .../src/components/drawer/DrawerLocations.tsx | 10 +- .../src/components/locations/GridLocation.tsx | 4 +- .../src/components/locations/ListLocation.tsx | 14 +- .../modal/inspector/ActionsModal.tsx | 16 +- .../modal/inspector/FileInfoModal.tsx | 21 +- .../src/components/overview/OverviewStats.tsx | 4 +- .../src/components/overview/StatCard.tsx | 10 +- apps/mobile/src/stores/searchStore.ts | 16 +- core/Cargo.toml | 20 +- core/crates/file-path-helper/Cargo.toml | 2 +- core/crates/heavy-lifting/Cargo.toml | 7 +- .../heavy-lifting/src/job_system/job.rs | 4 +- core/crates/indexer-rules/Cargo.toml | 5 +- core/crates/prisma-helpers/src/lib.rs | 30 +- core/crates/sync/src/backfill.rs | 14 +- .../migration.sql | 52 ++ .../migration.sql | 128 +++ core/prisma/schema.prisma | 209 ++++- core/src/api/ephemeral_files.rs | 85 +- core/src/api/files.rs | 27 +- .../search/{media_data.rs => exif_data.rs} | 10 +- core/src/api/search/mod.rs | 2 +- core/src/api/search/object.rs | 6 +- core/src/lib.rs | 1 + core/src/location/manager/watcher/utils.rs | 239 ++++-- core/src/location/non_indexed.rs | 16 +- ...xtractor.rs => exif_metadata_extractor.rs} | 89 +-- .../object/media/ffmpeg_metadata_extractor.rs | 660 +++++++++++++++ core/src/object/media/mod.rs | 239 +++++- .../object/media/old_media_processor/job.rs | 69 +- .../object/media/old_media_processor/mod.rs | 57 +- .../media/old_media_processor/shallow.rs | 69 +- .../src/object/media/old_thumbnail/process.rs | 19 +- crates/ai/Cargo.toml | 2 +- crates/crypto/Cargo.toml | 2 +- crates/ffmpeg/Cargo.toml | 13 +- crates/ffmpeg/README.md | 1 - crates/ffmpeg/src/codec_ctx.rs | 460 +++++++++++ crates/ffmpeg/src/dict.rs | 181 +++++ crates/ffmpeg/src/error.rs | 55 +- crates/ffmpeg/src/film_strip.rs | 693 ---------------- crates/ffmpeg/src/filter_graph.rs | 256 ++++++ crates/ffmpeg/src/format_ctx.rs | 427 ++++++++++ crates/ffmpeg/src/frame_decoder.rs | 290 +++++++ crates/ffmpeg/src/lib.rs | 105 ++- crates/ffmpeg/src/model.rs | 124 +++ crates/ffmpeg/src/movie_decoder.rs | 750 ------------------ crates/ffmpeg/src/thumbnailer.rs | 153 ++-- crates/ffmpeg/src/utils.rs | 13 +- crates/ffmpeg/src/video_frame.rs | 49 +- crates/file-ext/src/extensions.rs | 2 +- crates/media-metadata/Cargo.toml | 13 +- crates/media-metadata/src/audio.rs | 19 - crates/media-metadata/src/error.rs | 26 +- .../src/{image => exif}/composite.rs | 0 .../src/{image => exif}/consts.rs | 14 +- .../src/{image => exif}/datetime.rs | 4 +- .../src/{image => exif}/flash/consts.rs | 0 .../src/{image => exif}/flash/data.rs | 2 +- .../src/{image => exif}/flash/mod.rs | 0 .../src/{image => exif}/flash/values.rs | 0 .../{image => exif}/geographic/location.rs | 2 +- .../src/{image => exif}/geographic/mod.rs | 0 .../{image => exif}/geographic/pluscodes.rs | 2 +- crates/media-metadata/src/exif/mod.rs | 158 ++++ .../src/{image => exif}/orientation.rs | 0 .../src/{image => exif}/profile.rs | 0 .../src/{image => exif}/reader.rs | 17 +- .../src/{image => exif}/resolution.rs | 0 .../media-metadata/src/ffmpeg/audio_props.rs | 12 + crates/media-metadata/src/ffmpeg/chapter.rs | 14 + crates/media-metadata/src/ffmpeg/codec.rs | 22 + crates/media-metadata/src/ffmpeg/metadata.rs | 31 + crates/media-metadata/src/ffmpeg/mod.rs | 342 ++++++++ crates/media-metadata/src/ffmpeg/program.rs | 12 + crates/media-metadata/src/ffmpeg/stream.rs | 19 + .../src/ffmpeg/subtitle_props.rs | 8 + .../media-metadata/src/ffmpeg/video_props.rs | 19 + crates/media-metadata/src/image/mod.rs | 135 ---- crates/media-metadata/src/lib.rs | 32 +- crates/media-metadata/src/video.rs | 20 - crates/prisma/src/lib.rs | 1 + crates/task-system/Cargo.toml | 19 +- crates/utils/src/db.rs | 10 + .../Explorer/Inspector/MediaData.tsx | 264 +++--- .../$libraryId/Explorer/Inspector/index.tsx | 24 +- .../Explorer/View/GridView/Item/index.tsx | 4 +- .../Explorer/View/ListView/useTable.tsx | 4 +- .../app/$libraryId/overview/LibraryStats.tsx | 4 +- .../app/$libraryId/overview/LocationCard.tsx | 4 +- .../app/$libraryId/overview/StatCard.tsx | 10 +- .../app/$libraryId/settings/client/usage.tsx | 10 +- .../settings/library/locations/ListItem.tsx | 6 +- interface/locales/README.md | 6 +- interface/locales/ar/common.json | 3 + interface/locales/be/common.json | 6 + interface/locales/de/common.json | 3 + interface/locales/en/common.json | 3 + interface/locales/es/common.json | 6 + interface/locales/fr/common.json | 7 + interface/locales/it/common.json | 7 + interface/locales/ja/common.json | 3 + interface/locales/nl/common.json | 3 + interface/locales/ru/common.json | 6 + interface/locales/tr/common.json | 3 + interface/locales/zh-CN/common.json | 3 + interface/locales/zh-TW/common.json | 3 + interface/util/keybinds.ts | 5 +- package.json | 5 +- packages/client/src/core.ts | 48 +- packages/client/src/lib/byte-size.ts | 78 -- packages/client/src/lib/explorerItem.ts | 16 +- packages/client/src/lib/humanizeSize.ts | 140 ++++ packages/client/src/lib/index.ts | 2 +- packages/client/src/utils/index.ts | 9 + packages/client/src/utils/jobs/useJobInfo.tsx | 4 +- packages/ui/src/Dialog.tsx | 6 +- rust-toolchain.toml | 2 +- 132 files changed, 4961 insertions(+), 2548 deletions(-) create mode 100644 core/prisma/migrations/20240409202941_rename_media_data_to_exif_data/migration.sql create mode 100644 core/prisma/migrations/20240508074615_ffmpeg_media_data/migration.sql rename core/src/api/search/{media_data.rs => exif_data.rs} (71%) rename core/src/object/media/{media_data_extractor.rs => exif_metadata_extractor.rs} (55%) create mode 100644 core/src/object/media/ffmpeg_metadata_extractor.rs create mode 100644 crates/ffmpeg/src/codec_ctx.rs create mode 100644 crates/ffmpeg/src/dict.rs delete mode 100644 crates/ffmpeg/src/film_strip.rs create mode 100644 crates/ffmpeg/src/filter_graph.rs create mode 100644 crates/ffmpeg/src/format_ctx.rs create mode 100644 crates/ffmpeg/src/frame_decoder.rs create mode 100644 crates/ffmpeg/src/model.rs delete mode 100644 crates/ffmpeg/src/movie_decoder.rs delete mode 100644 crates/media-metadata/src/audio.rs rename crates/media-metadata/src/{image => exif}/composite.rs (100%) rename crates/media-metadata/src/{image => exif}/consts.rs (84%) rename crates/media-metadata/src/{image => exif}/datetime.rs (97%) rename crates/media-metadata/src/{image => exif}/flash/consts.rs (100%) rename crates/media-metadata/src/{image => exif}/flash/data.rs (98%) rename crates/media-metadata/src/{image => exif}/flash/mod.rs (100%) rename crates/media-metadata/src/{image => exif}/flash/values.rs (100%) rename crates/media-metadata/src/{image => exif}/geographic/location.rs (99%) rename crates/media-metadata/src/{image => exif}/geographic/mod.rs (100%) rename crates/media-metadata/src/{image => exif}/geographic/pluscodes.rs (98%) create mode 100644 crates/media-metadata/src/exif/mod.rs rename crates/media-metadata/src/{image => exif}/orientation.rs (100%) rename crates/media-metadata/src/{image => exif}/profile.rs (100%) rename crates/media-metadata/src/{image => exif}/reader.rs (76%) rename crates/media-metadata/src/{image => exif}/resolution.rs (100%) create mode 100644 crates/media-metadata/src/ffmpeg/audio_props.rs create mode 100644 crates/media-metadata/src/ffmpeg/chapter.rs create mode 100644 crates/media-metadata/src/ffmpeg/codec.rs create mode 100644 crates/media-metadata/src/ffmpeg/metadata.rs create mode 100644 crates/media-metadata/src/ffmpeg/mod.rs create mode 100644 crates/media-metadata/src/ffmpeg/program.rs create mode 100644 crates/media-metadata/src/ffmpeg/stream.rs create mode 100644 crates/media-metadata/src/ffmpeg/subtitle_props.rs create mode 100644 crates/media-metadata/src/ffmpeg/video_props.rs delete mode 100644 crates/media-metadata/src/image/mod.rs delete mode 100644 crates/media-metadata/src/video.rs delete mode 100644 packages/client/src/lib/byte-size.ts create mode 100644 packages/client/src/lib/humanizeSize.ts diff --git a/.cspell/project_words.txt b/.cspell/project_words.txt index 748c746de..e850b886f 100644 --- a/.cspell/project_words.txt +++ b/.cspell/project_words.txt @@ -11,9 +11,11 @@ codegen Condvar dashmap davidmytton +dayjs deel elon encryptor +Exif Flac graps haden @@ -59,7 +61,9 @@ storedkey stringly thumbstrips tobiaslutke +tokio typecheck +uuid vdfs vijay zacharysmith diff --git a/.github/actions/setup-rust/action.yaml b/.github/actions/setup-rust/action.yaml index a4aa92a09..8498f184a 100644 --- a/.github/actions/setup-rust/action.yaml +++ b/.github/actions/setup-rust/action.yaml @@ -17,10 +17,9 @@ runs: steps: - name: Install Rust id: toolchain - uses: dtolnay/rust-toolchain@stable + uses: IronCoreLabs/rust-toolchain@v1 with: target: ${{ inputs.target }} - toolchain: '1.75' components: clippy, rustfmt - name: Cache Rust Dependencies diff --git a/.vscode/launch.json b/.vscode/launch.json index 6ae503123..411d7f5a1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,8 +12,7 @@ "args": [ "build", "--manifest-path=./apps/desktop/src-tauri/Cargo.toml", - "--no-default-features", - "--features=ai-models" + "--no-default-features" ], "problemMatcher": "$rustc" }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2a7510da5..a81b441ce 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -56,8 +56,7 @@ "command": "run", "args": [ "--manifest-path=./apps/desktop/src-tauri/Cargo.toml", - "--no-default-features", - "--features=ai-models" + "--no-default-features" ], "env": { "RUST_BACKTRACE": "short" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48af3989d..efa04a378 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ To run the landing page: If you encounter any issues, ensure that you are using the following versions of Rust, Node and Pnpm: -- Rust version: **1.75** +- Rust version: **1.78** - Node version: **18.18** - Pnpm version: **9.0.6** diff --git a/Cargo.lock b/Cargo.lock index 997d6fb70..8625cbfec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1673,9 +1673,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.31" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", @@ -1683,7 +1683,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.48.5", + "windows-targets 0.52.5", ] [[package]] @@ -9054,7 +9054,11 @@ dependencies = [ name = "sd-ffmpeg" version = "0.1.0" dependencies = [ + "chrono", "ffmpeg-sys-next", + "image", + "libc", + "sd-utils", "tempfile", "thiserror", "tokio", @@ -9101,10 +9105,13 @@ dependencies = [ "kamadak-exif", "rand 0.8.5", "rand_chacha 0.3.1", + "sd-ffmpeg", + "sd-utils", "serde", "serde_json", "specta", "thiserror", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0cebcf151..e7445ea08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,25 +22,20 @@ repository = "https://github.com/spacedriveapp/spacedrive" [workspace.dependencies] # First party dependencies prisma-client-rust = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "528ab1cd02c25a1b183c0a8bc44e28954fdd0bfd", features = [ - "specta", - "sqlite-create-many", "migrations", + "specta", "sqlite", + "sqlite-create-many", ], default-features = false } prisma-client-rust-cli = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "528ab1cd02c25a1b183c0a8bc44e28954fdd0bfd", features = [ - "specta", - "sqlite-create-many", "migrations", + "specta", "sqlite", + "sqlite-create-many", ], default-features = false } prisma-client-rust-sdk = { git = "https://github.com/spacedriveapp/prisma-client-rust", rev = "528ab1cd02c25a1b183c0a8bc44e28954fdd0bfd", features = [ "sqlite", ], default-features = false } - -tracing = "0.1.40" -tracing-subscriber = "0.3.18" -tracing-appender = "0.2.3" - rspc = { version = "0.1.4" } specta = { version = "=2.0.0-rc.11" } tauri-specta = { version = "=2.0.0-rc.8" } @@ -54,7 +49,7 @@ async-trait = "0.1.77" axum = "=0.6.20" base64 = "0.21.5" blake3 = "1.5.0" -chrono = "0.4.31" +chrono = "0.4.38" clap = "4.4.7" futures = "0.3.30" futures-concurrency = "7.4.3" @@ -64,6 +59,7 @@ http = "0.2.9" image = "0.24.7" itertools = "0.12.0" lending-stream = "1.0.0" +libc = "0.2" normpath = "1.1.1" once_cell = "1.18.0" pin-project-lite = "0.2.13" @@ -83,13 +79,14 @@ thiserror = "1.0.50" tokio = "1.36.0" tokio-stream = "0.1.14" tokio-util = "0.7.10" +tracing = "0.1.40" +tracing-subscriber = "0.3.18" +tracing-appender = "0.2.3" +tracing-test = "^0.2.4" uhlc = "=0.5.2" uuid = "1.5.0" webp = "0.2.6" -[workspace.dev-dependencies] -tracing-test = { version = "^0.2.4" } - [patch.crates-io] # Proper IOS Support if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3" } diff --git a/apps/desktop/crates/linux/Cargo.toml b/apps/desktop/crates/linux/Cargo.toml index 2577f13d8..b61d10fc2 100644 --- a/apps/desktop/crates/linux/Cargo.toml +++ b/apps/desktop/crates/linux/Cargo.toml @@ -7,7 +7,7 @@ edition = { workspace = true } [dependencies] tokio = { workspace = true, features = ["fs"] } -libc = "0.2" +libc = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] # WARNING: gtk should follow the same version used by tauri diff --git a/apps/desktop/crates/linux/src/app_info.rs b/apps/desktop/crates/linux/src/app_info.rs index 01564cbfe..5b2767670 100644 --- a/apps/desktop/crates/linux/src/app_info.rs +++ b/apps/desktop/crates/linux/src/app_info.rs @@ -2,31 +2,14 @@ use std::path::Path; use gtk::{ gio::{ - content_type_guess, - prelude::AppInfoExt, - prelude::{AppLaunchContextExt, FileExt}, - AppInfo, AppLaunchContext, DesktopAppInfo, File as GioFile, ResourceError, + content_type_guess, prelude::AppInfoExt, prelude::FileExt, AppInfo, AppLaunchContext, + DesktopAppInfo, File as GioFile, ResourceError, }, glib::error::Error as GlibError, - prelude::IsA, }; use tokio::fs::File; use tokio::io::AsyncReadExt; -use crate::env::remove_prefix_from_pathlist; - -fn remove_prefix_from_env_in_ctx( - ctx: &impl IsA, - env_name: &str, - prefix: &impl AsRef, -) { - if let Some(value) = remove_prefix_from_pathlist(env_name, prefix) { - ctx.setenv(env_name, value); - } else { - ctx.unsetenv(env_name); - } -} - thread_local! { static LAUNCH_CTX: AppLaunchContext = { // TODO: Display supports requires GDK, which can only run on the main thread diff --git a/apps/desktop/crates/linux/src/env.rs b/apps/desktop/crates/linux/src/env.rs index 29bf5c8e6..b9bcc0eba 100644 --- a/apps/desktop/crates/linux/src/env.rs +++ b/apps/desktop/crates/linux/src/env.rs @@ -1,29 +1,13 @@ use std::{ collections::HashSet, env, - ffi::{CStr, OsStr, OsString}, + ffi::{CStr, OsStr}, mem, os::unix::ffi::OsStrExt, - path::{Path, PathBuf}, + path::PathBuf, ptr, }; -fn version(version_str: &str) -> i32 { - let mut version_parts: Vec = version_str - .split('.') - .take(4) // Take up to 4 components - .map(|part| part.parse().unwrap_or(0)) - .collect(); - - // Pad with zeros if needed - version_parts.resize_with(4, Default::default); - - (version_parts[0] * 1_000_000_000) - + (version_parts[1] * 1_000_000) - + (version_parts[2] * 1_000) - + version_parts[3] -} - pub fn get_current_user_home() -> Option { use libc::{getpwuid_r, getuid, passwd, ERANGE}; @@ -193,23 +177,6 @@ pub fn normalize_environment() { .expect("PATH must be successfully normalized"); } -pub(crate) fn remove_prefix_from_pathlist( - env_name: &str, - prefix: &impl AsRef, -) -> Option { - env::var_os(env_name).and_then(|value| { - let mut dirs = env::split_paths(&value) - .filter(|dir| !(dir.as_os_str().is_empty() || dir.starts_with(prefix))) - .peekable(); - - if dirs.peek().is_none() { - None - } else { - Some(env::join_paths(dirs).expect("Should not fail because we are only filtering a pathlist retrieved from the environmnet")) - } - }) -} - // Check if snap by looking if SNAP is set and not empty and that the SNAP directory exists pub fn is_snap() -> bool { if let Some(snap) = std::env::var_os("SNAP") { diff --git a/apps/desktop/crates/windows/Cargo.toml b/apps/desktop/crates/windows/Cargo.toml index f9a4ba616..d0e143bb1 100644 --- a/apps/desktop/crates/windows/Cargo.toml +++ b/apps/desktop/crates/windows/Cargo.toml @@ -8,7 +8,7 @@ edition = { workspace = true } [dependencies] normpath = { workspace = true } thiserror = { workspace = true } -libc = "0.2" +libc = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies.windows] version = "0.51" diff --git a/apps/mobile/modules/sd-core/android/crate/Cargo.toml b/apps/mobile/modules/sd-core/android/crate/Cargo.toml index 31a6cfb93..98b965841 100644 --- a/apps/mobile/modules/sd-core/android/crate/Cargo.toml +++ b/apps/mobile/modules/sd-core/android/crate/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sd-mobile-android" version = "0.1.0" -rust-version = "1.64.0" +rust-version = "1.64" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/apps/mobile/modules/sd-core/core/Cargo.toml b/apps/mobile/modules/sd-core/core/Cargo.toml index 25685d0f0..37ab7df16 100644 --- a/apps/mobile/modules/sd-core/core/Cargo.toml +++ b/apps/mobile/modules/sd-core/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sd-mobile-core" version = "0.1.0" -rust-version = "1.64.0" +rust-version = "1.64" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/apps/mobile/modules/sd-core/ios/crate/Cargo.toml b/apps/mobile/modules/sd-core/ios/crate/Cargo.toml index 11ec5734c..bf08895d6 100644 --- a/apps/mobile/modules/sd-core/ios/crate/Cargo.toml +++ b/apps/mobile/modules/sd-core/ios/crate/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sd-mobile-ios" version = "0.1.0" -rust-version = "1.64.0" +rust-version = "1.64" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/apps/mobile/src/components/drawer/DrawerLocations.tsx b/apps/mobile/src/components/drawer/DrawerLocations.tsx index 83e29685c..28a367cb5 100644 --- a/apps/mobile/src/components/drawer/DrawerLocations.tsx +++ b/apps/mobile/src/components/drawer/DrawerLocations.tsx @@ -2,7 +2,13 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript import { useNavigation } from '@react-navigation/native'; import { useRef } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { arraysEqual, byteSize, Location, useLibraryQuery, useOnlineLocations } from '@sd/client'; +import { + arraysEqual, + humanizeSize, + Location, + useLibraryQuery, + useOnlineLocations +} from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw, twStyle } from '~/lib/tailwind'; @@ -45,7 +51,7 @@ const DrawerLocationItem: React.FC = ({ - {`${byteSize(location.size_in_bytes)}`} + {`${humanizeSize(location.size_in_bytes)}`} diff --git a/apps/mobile/src/components/locations/GridLocation.tsx b/apps/mobile/src/components/locations/GridLocation.tsx index 5a9ff67c3..ed40b9c25 100644 --- a/apps/mobile/src/components/locations/GridLocation.tsx +++ b/apps/mobile/src/components/locations/GridLocation.tsx @@ -1,6 +1,6 @@ import { DotsThreeOutlineVertical } from 'phosphor-react-native'; import { Pressable, Text, View } from 'react-native'; -import { arraysEqual, byteSize, Location, useOnlineLocations } from '@sd/client'; +import { arraysEqual, humanizeSize, Location, useOnlineLocations } from '@sd/client'; import { tw, twStyle } from '~/lib/tailwind'; import FolderIcon from '../icons/FolderIcon'; @@ -47,7 +47,7 @@ const GridLocation: React.FC = ({ location, modalRef }: GridL - {`${byteSize(location.size_in_bytes)}`} + {`${humanizeSize(location.size_in_bytes)}`} ); diff --git a/apps/mobile/src/components/locations/ListLocation.tsx b/apps/mobile/src/components/locations/ListLocation.tsx index a346d708c..36774aa3b 100644 --- a/apps/mobile/src/components/locations/ListLocation.tsx +++ b/apps/mobile/src/components/locations/ListLocation.tsx @@ -1,9 +1,9 @@ import { useNavigation } from '@react-navigation/native'; -import { Location, arraysEqual, byteSize, useOnlineLocations } from '@sd/client'; import { DotsThreeVertical } from 'phosphor-react-native'; import { useRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; +import { arraysEqual, humanizeSize, Location, useOnlineLocations } from '@sd/client'; import { tw, twStyle } from '~/lib/tailwind'; import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack'; @@ -62,22 +62,16 @@ const ListLocation = ({ location }: ListLocationProps) => { - + - {`${byteSize(location.size_in_bytes)}`} + {`${humanizeSize(location.size_in_bytes)}`} swipeRef.current?.openRight()}> - + diff --git a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx index 944b10e51..a6a228a63 100644 --- a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx +++ b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx @@ -1,10 +1,3 @@ -import { - byteSize, - getIndexedItemFilePath, - getItemObject, - useLibraryMutation, - useLibraryQuery -} from '@sd/client'; import dayjs from 'dayjs'; import { Copy, @@ -20,6 +13,13 @@ import { import { PropsWithChildren, useRef } from 'react'; import { Pressable, Text, View, ViewStyle } from 'react-native'; import FileViewer from 'react-native-file-viewer'; +import { + getIndexedItemFilePath, + getItemObject, + humanizeSize, + useLibraryMutation, + useLibraryQuery +} from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import FavoriteButton from '~/components/explorer/sections/FavoriteButton'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; @@ -119,7 +119,7 @@ export const ActionsModal = () => { - {`${byteSize(filePath?.size_in_bytes_bytes)}`}, + {`${humanizeSize(filePath?.size_in_bytes_bytes)}`}, {' '} diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index 9df463d73..1bd332531 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -2,7 +2,7 @@ import dayjs from 'dayjs'; import { Barcode, CaretLeft, Clock, Cube, Icon, SealCheck, Snowflake } from 'phosphor-react-native'; import { forwardRef } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { byteSize, getItemFilePath, getItemObject, type ExplorerItem } from '@sd/client'; +import { getItemFilePath, humanizeSize, type ExplorerItem } from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; import { Modal, ModalScrollView, type ModalRef } from '~/components/layout/Modal'; @@ -39,18 +39,9 @@ type FileInfoModalProps = { const FileInfoModal = forwardRef((props, ref) => { const { data } = props; - const modalRef = useForwardedRef(ref); - - const item = data?.item; - - const objectData = data && getItemObject(data); const filePathData = data && getItemFilePath(data); - // const fullObjectData = useLibraryQuery(['files.get', objectData?.id || -1], { - // enabled: objectData?.id !== undefined - // }); - return ( ((props, ref) => { - {/* Duration */} - {/* {fullObjectData.data?.media_data?.duration && ( - - )} */} {/* Created */} {data.type !== 'SpacedropPeer' && ( { - const { value, unit } = byteSize(bytes); + const { value, unit } = humanizeSize(bytes); const count = useCounter({ name: title, end: value }); diff --git a/apps/mobile/src/components/overview/StatCard.tsx b/apps/mobile/src/components/overview/StatCard.tsx index 8dc98e969..5ef84bd64 100644 --- a/apps/mobile/src/components/overview/StatCard.tsx +++ b/apps/mobile/src/components/overview/StatCard.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Text, View } from 'react-native'; import { AnimatedCircularProgress } from 'react-native-circular-progress'; -import { byteSize } from '@sd/client'; +import { humanizeSize } from '@sd/client'; import { tw } from '~/lib/tailwind'; import { Icon, IconName } from '../icons/Icon'; @@ -20,12 +20,12 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => { const [mounted, setMounted] = useState(false); const { totalSpace, freeSpace, usedSpaceSpace } = useMemo(() => { - const totalSpace = byteSize(stats.totalSpace); - const freeSpace = stats.freeSpace == null ? totalSpace : byteSize(stats.freeSpace); + const totalSpace = humanizeSize(stats.totalSpace); + const freeSpace = stats.freeSpace == null ? totalSpace : humanizeSize(stats.freeSpace); return { totalSpace, freeSpace, - usedSpaceSpace: byteSize(totalSpace.original - freeSpace.original) + usedSpaceSpace: humanizeSize(totalSpace.original - freeSpace.original) }; }, [stats]); @@ -34,7 +34,7 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => { }, []); const progress = useMemo(() => { - if (!mounted || totalSpace.original === 0n) return 0; + if (!mounted || totalSpace.original === 0) return 0; return Math.floor((usedSpaceSpace.value / totalSpace.value) * 100); }, [mounted, totalSpace, usedSpaceSpace]); diff --git a/apps/mobile/src/stores/searchStore.ts b/apps/mobile/src/stores/searchStore.ts index 6b469811a..e8131364c 100644 --- a/apps/mobile/src/stores/searchStore.ts +++ b/apps/mobile/src/stores/searchStore.ts @@ -1,12 +1,20 @@ -import { SearchFilterArgs } from '@sd/client'; import { proxy, useSnapshot } from 'valtio'; +import { SearchFilterArgs } from '@sd/client'; import { IconName } from '~/components/icons/Icon'; export type SearchFilters = 'locations' | 'tags' | 'name' | 'extension' | 'hidden' | 'kind'; export type SortOptionsType = { - by: 'none' | 'name' | 'sizeInBytes' | 'dateIndexed' | 'dateCreated' | 'dateModified' | 'dateAccessed' | 'dateTaken'; + by: + | 'none' + | 'name' + | 'sizeInBytes' + | 'dateIndexed' + | 'dateCreated' + | 'dateModified' + | 'dateAccessed' + | 'dateTaken'; direction: 'Asc' | 'Desc'; -} +}; export interface FilterItem { id: number; @@ -38,7 +46,7 @@ interface State { filters: Filters; sort: SortOptionsType; appliedFilters: Partial; - mergedFilters: SearchFilterArgs[], + mergedFilters: SearchFilterArgs[]; disableActionButtons: boolean; } diff --git a/core/Cargo.toml b/core/Cargo.toml index 77d9b810e..9fb13341a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -2,8 +2,8 @@ name = "sd-core" version = "0.2.14" description = "Virtual distributed filesystem engine that powers Spacedrive." -authors = ["Spacedrive Technology Inc."] -rust-version = "1.75.0" +authors = ["Spacedrive Technology Inc "] +rust-version = "1.78" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } @@ -13,7 +13,7 @@ default = [] # This feature allows features to be disabled when the Core is running on mobile. mobile = [] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg. -ffmpeg = ["dep:sd-ffmpeg"] +ffmpeg = ["dep:sd-ffmpeg", "sd-media-metadata/ffmpeg"] heif = ["sd-images/heif"] ai = ["dep:sd-ai"] crypto = ["dep:sd-crypto"] @@ -25,11 +25,10 @@ sd-core-heavy-lifting = { path = "./crates/heavy-lifting" } sd-core-indexer-rules = { path = "./crates/indexer-rules" } sd-core-prisma-helpers = { path = "./crates/prisma-helpers" } sd-core-sync = { path = "./crates/sync" } - # Spacedrive Sub-crates -sd-actors = { version = "0.1.0", path = "../crates/actors" } +sd-actors = { path = "../crates/actors", version = "0.1.0" } sd-ai = { path = "../crates/ai", optional = true } -sd-cloud-api = { version = "0.1.0", path = "../crates/cloud-api" } +sd-cloud-api = { path = "../crates/cloud-api", version = "0.1.0" } sd-crypto = { path = "../crates/crypto", features = [ "sys", "tokio", @@ -61,6 +60,7 @@ futures = { workspace = true } futures-concurrency = { workspace = true } image = { workspace = true } itertools = { workspace = true } +libc = { workspace = true } normpath = { workspace = true, features = ["localization"] } once_cell = { workspace = true } pin-project-lite = { workspace = true } @@ -101,7 +101,6 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } uuid = { workspace = true, features = ["v4", "serde"] } webp = { workspace = true } - # Specific Core dependencies async-recursion = "1.0.5" async-stream = "0.3.5" @@ -118,7 +117,6 @@ http-body = "0.4.5" http-range = "0.1.5" hyper = { version = "=0.14.28", features = ["http1", "server", "client"] } int-enum = "0.5.0" -libc = "0.2.153" mini-moka = "0.10.2" notify = { git = "https://github.com/notify-rs/notify.git", rev = "c3929ed114fbb0bc7457a9a498260461596b00ca", default-features = false, features = [ "macos_fsevent", @@ -160,6 +158,8 @@ icrate = { version = "0.1.0", features = [ ] } [dev-dependencies] -tracing-test = { workspace.dev-dependencies = true } -aovec = "1.1.0" +# Workspace dependencies globset = { workspace = true } +tracing-test = { workspace = true } +# Specific Core dependencies +aovec = "1.1.0" diff --git a/core/crates/file-path-helper/Cargo.toml b/core/crates/file-path-helper/Cargo.toml index aceebb563..38fdf86b7 100644 --- a/core/crates/file-path-helper/Cargo.toml +++ b/core/crates/file-path-helper/Cargo.toml @@ -3,7 +3,7 @@ name = "sd-core-file-path-helper" version = "0.1.0" authors = ["Ericson Soares "] readme = "README.md" -rust-version = "1.75.0" +rust-version = "1.75" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/core/crates/heavy-lifting/Cargo.toml b/core/crates/heavy-lifting/Cargo.toml index fb73a63fd..380443708 100644 --- a/core/crates/heavy-lifting/Cargo.toml +++ b/core/crates/heavy-lifting/Cargo.toml @@ -14,15 +14,13 @@ sd-core-file-path-helper = { path = "../file-path-helper" } sd-core-indexer-rules = { path = "../indexer-rules" } sd-core-prisma-helpers = { path = "../prisma-helpers" } sd-core-sync = { path = "../sync" } - # Sub-crates sd-file-ext = { path = "../../../crates/file-ext" } sd-prisma = { path = "../../../crates/prisma" } sd-sync = { path = "../../../crates/sync" } sd-task-system = { path = "../../../crates/task-system" } sd-utils = { path = "../../../crates/utils" } - - +# Workspace dependencies async-channel = { workspace = true } async-trait = { workspace = true } blake3 = { workspace = true } @@ -47,7 +45,6 @@ tokio-stream = { workspace = true, features = ["fs"] } tracing = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } - [dev-dependencies] tempfile = { workspace = true } -tracing-test = { workspace.dev-dependencies = true } +tracing-test = { workspace = true } diff --git a/core/crates/heavy-lifting/src/job_system/job.rs b/core/crates/heavy-lifting/src/job_system/job.rs index 191d71148..eadb587e1 100644 --- a/core/crates/heavy-lifting/src/job_system/job.rs +++ b/core/crates/heavy-lifting/src/job_system/job.rs @@ -8,8 +8,8 @@ use sd_task_system::{ }; use std::{ - collections::VecDeque, - hash::{DefaultHasher, Hash, Hasher}, + collections::{hash_map::DefaultHasher, VecDeque}, + hash::{Hash, Hasher}, marker::PhantomData, pin::pin, sync::Arc, diff --git a/core/crates/indexer-rules/Cargo.toml b/core/crates/indexer-rules/Cargo.toml index 5ac05ea8f..aef5755db 100644 --- a/core/crates/indexer-rules/Cargo.toml +++ b/core/crates/indexer-rules/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "sd-core-indexer-rules" version = "0.1.0" -authors = ["Ericson Soares "] +authors = [ + "Ericson Soares ", + "Vítor Vasconcellos ", +] license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/core/crates/prisma-helpers/src/lib.rs b/core/crates/prisma-helpers/src/lib.rs index a35eda9be..a2748c9af 100644 --- a/core/crates/prisma-helpers/src/lib.rs +++ b/core/crates/prisma-helpers/src/lib.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "256"] #![warn( clippy::all, clippy::pedantic, @@ -139,7 +140,7 @@ file_path::select!(file_path_to_full_path { // File Path includes! file_path::include!(file_path_with_object { object: include { - media_data: select { + exif_data: select { resolution media_date media_location @@ -162,7 +163,7 @@ object::select!(object_for_file_identifier { object::include!(object_with_file_paths { file_paths: include { object: include { - media_data: select { + exif_data: select { resolution media_date media_location @@ -172,6 +173,31 @@ object::include!(object_with_file_paths { copyright exif_version } + ffmpeg_data: include { + chapters + programs: include { + streams: include { + codec: include { + audio_props + video_props + } + } + } + } + } + } +}); +object::include!(object_with_media_data { + exif_data + ffmpeg_data: include { + chapters + programs: include { + streams: include { + codec: include { + audio_props + video_props + } + } } } }); diff --git a/core/crates/sync/src/backfill.rs b/core/crates/sync/src/backfill.rs index 78d8a280e..a383f8742 100644 --- a/core/crates/sync/src/backfill.rs +++ b/core/crates/sync/src/backfill.rs @@ -2,7 +2,7 @@ use std::future::Future; use sd_prisma::{ prisma::{ - crdt_operation, file_path, label, label_on_object, location, media_data, object, tag, + crdt_operation, exif_data, file_path, label, label_on_object, location, object, tag, tag_on_object, PrismaClient, SortOrder, }, prisma_sync, @@ -163,11 +163,11 @@ pub async fn backfill_operations(db: &PrismaClient, sync: &crate::Manager, insta paginate( |cursor| { - db.media_data() - .find_many(vec![media_data::id::gt(cursor)]) - .order_by(media_data::id::order(SortOrder::Asc)) + db.exif_data() + .find_many(vec![exif_data::id::gt(cursor)]) + .order_by(exif_data::id::order(SortOrder::Asc)) .take(1000) - .include(media_data::include!({ + .include(exif_data::include!({ object: select { pub_id } })) .exec() @@ -179,10 +179,10 @@ pub async fn backfill_operations(db: &PrismaClient, sync: &crate::Manager, insta media_datas .into_iter() .flat_map(|md| { - use media_data::*; + use exif_data::*; sync.shared_create( - prisma_sync::media_data::SyncId { + prisma_sync::exif_data::SyncId { object: prisma_sync::object::SyncId { pub_id: md.object.pub_id, }, diff --git a/core/prisma/migrations/20240409202941_rename_media_data_to_exif_data/migration.sql b/core/prisma/migrations/20240409202941_rename_media_data_to_exif_data/migration.sql new file mode 100644 index 000000000..c9069a8de --- /dev/null +++ b/core/prisma/migrations/20240409202941_rename_media_data_to_exif_data/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "exif_data" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "resolution" BLOB, + "media_date" BLOB, + "media_location" BLOB, + "camera_data" BLOB, + "artist" TEXT, + "description" TEXT, + "copyright" TEXT, + "exif_version" TEXT, + "epoch_time" BIGINT, + "object_id" INTEGER NOT NULL, + CONSTRAINT "exif_data_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CopyData +INSERT INTO "exif_data" ( + "id", + "resolution", + "media_date", + "media_location", + "camera_data", + "artist", + "description", + "copyright", + "exif_version", + "epoch_time", + "object_id" +) +SELECT + "id", + "resolution", + "media_date", + "media_location", + "camera_data", + "artist", + "description", + "copyright", + "exif_version", + "epoch_time", + "object_id" +FROM + "media_data"; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "media_data"; +PRAGMA foreign_keys=on; + +-- CreateIndex +CREATE UNIQUE INDEX "exif_data_object_id_key" ON "exif_data"("object_id"); diff --git a/core/prisma/migrations/20240508074615_ffmpeg_media_data/migration.sql b/core/prisma/migrations/20240508074615_ffmpeg_media_data/migration.sql new file mode 100644 index 000000000..2bc689a7e --- /dev/null +++ b/core/prisma/migrations/20240508074615_ffmpeg_media_data/migration.sql @@ -0,0 +1,128 @@ +-- CreateTable +CREATE TABLE "ffmpeg_data" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "formats" TEXT NOT NULL, + "bit_rate" BLOB NOT NULL, + "duration" BLOB, + "start_time" BLOB, + "title" TEXT, + "creation_time" DATETIME, + "date" DATETIME, + "album_artist" TEXT, + "disc" TEXT, + "track" TEXT, + "album" TEXT, + "artist" TEXT, + "metadata" BLOB, + "object_id" INTEGER NOT NULL, + CONSTRAINT "ffmpeg_data_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_chapter" ( + "chapter_id" INTEGER NOT NULL, + "start" BLOB NOT NULL, + "end" BLOB NOT NULL, + "time_base_den" INTEGER NOT NULL, + "time_base_num" INTEGER NOT NULL, + "title" TEXT, + "metadata" BLOB, + "ffmpeg_data_id" INTEGER NOT NULL, + + PRIMARY KEY ("ffmpeg_data_id", "chapter_id"), + CONSTRAINT "ffmpeg_media_chapter_ffmpeg_data_id_fkey" FOREIGN KEY ("ffmpeg_data_id") REFERENCES "ffmpeg_data" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_program" ( + "program_id" INTEGER NOT NULL, + "name" TEXT, + "metadata" BLOB, + "ffmpeg_data_id" INTEGER NOT NULL, + + PRIMARY KEY ("ffmpeg_data_id", "program_id"), + CONSTRAINT "ffmpeg_media_program_ffmpeg_data_id_fkey" FOREIGN KEY ("ffmpeg_data_id") REFERENCES "ffmpeg_data" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_stream" ( + "stream_id" INTEGER NOT NULL, + "name" TEXT, + "aspect_ratio_num" INTEGER NOT NULL, + "aspect_ratio_den" INTEGER NOT NULL, + "frames_per_second_num" INTEGER NOT NULL, + "frames_per_second_den" INTEGER NOT NULL, + "time_base_real_den" INTEGER NOT NULL, + "time_base_real_num" INTEGER NOT NULL, + "dispositions" TEXT, + "title" TEXT, + "encoder" TEXT, + "language" TEXT, + "duration" BLOB, + "metadata" BLOB, + "program_id" INTEGER NOT NULL, + "ffmpeg_data_id" INTEGER NOT NULL, + + PRIMARY KEY ("ffmpeg_data_id", "program_id", "stream_id"), + CONSTRAINT "ffmpeg_media_stream_ffmpeg_data_id_program_id_fkey" FOREIGN KEY ("ffmpeg_data_id", "program_id") REFERENCES "ffmpeg_media_program" ("ffmpeg_data_id", "program_id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_codec" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "kind" TEXT, + "sub_kind" TEXT, + "tag" TEXT, + "name" TEXT, + "profile" TEXT, + "bit_rate" INTEGER NOT NULL, + "stream_id" INTEGER NOT NULL, + "program_id" INTEGER NOT NULL, + "ffmpeg_data_id" INTEGER NOT NULL, + CONSTRAINT "ffmpeg_media_codec_ffmpeg_data_id_program_id_stream_id_fkey" FOREIGN KEY ("ffmpeg_data_id", "program_id", "stream_id") REFERENCES "ffmpeg_media_stream" ("ffmpeg_data_id", "program_id", "stream_id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_video_props" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "pixel_format" TEXT, + "color_range" TEXT, + "bits_per_channel" INTEGER, + "color_space" TEXT, + "color_primaries" TEXT, + "color_transfer" TEXT, + "field_order" TEXT, + "chroma_location" TEXT, + "width" INTEGER NOT NULL, + "height" INTEGER NOT NULL, + "aspect_ratio_num" INTEGER, + "aspect_ratio_Den" INTEGER, + "properties" TEXT, + "codec_id" INTEGER NOT NULL, + CONSTRAINT "ffmpeg_media_video_props_codec_id_fkey" FOREIGN KEY ("codec_id") REFERENCES "ffmpeg_media_codec" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ffmpeg_media_audio_props" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "delay" INTEGER NOT NULL, + "padding" INTEGER NOT NULL, + "sample_rate" INTEGER, + "sample_format" TEXT, + "bit_per_sample" INTEGER, + "channel_layout" TEXT, + "codec_id" INTEGER NOT NULL, + CONSTRAINT "ffmpeg_media_audio_props_codec_id_fkey" FOREIGN KEY ("codec_id") REFERENCES "ffmpeg_media_codec" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "ffmpeg_data_object_id_key" ON "ffmpeg_data"("object_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "ffmpeg_media_codec_ffmpeg_data_id_program_id_stream_id_key" ON "ffmpeg_media_codec"("ffmpeg_data_id", "program_id", "stream_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "ffmpeg_media_video_props_codec_id_key" ON "ffmpeg_media_video_props"("codec_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "ffmpeg_media_audio_props_codec_id_key" ON "ffmpeg_media_audio_props"("codec_id"); diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 3e2fd3535..f4a4f3fda 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -231,33 +231,23 @@ model Object { date_created DateTime? date_accessed DateTime? - tags TagOnObject[] - labels LabelOnObject[] - albums ObjectInAlbum[] - spaces ObjectInSpace[] - file_paths FilePath[] + tags TagOnObject[] + labels LabelOnObject[] + albums ObjectInAlbum[] + spaces ObjectInSpace[] + file_paths FilePath[] // comments Comment[] - media_data MediaData? + exif_data ExifData? + ffmpeg_data FfmpegData? // key Key? @relation(fields: [key_id], references: [id]) @@map("object") } -// if there is a conflicting cas_id, the conficting file should be updated to have a larger cas_id as -//the field is unique, however this record is kept to tell the indexer (upon discovering this CAS) that -//there is alternate versions of the file and to check by a full integrity hash to define for which to associate with. -// @brendan: nah this probably won't fly -// model FileConflict { -// original_object_id Int @unique -// detactched_object_id Int @unique - -// @@map("file_conflict") -// } - -// keys allow us to know exactly which files can be decrypted with a given key -// they can be "mounted" to a client, and then used to decrypt files automatically -/// @shared(id: uuid) +// // keys allow us to know exactly which files can be decrypted with a given key +// // they can be "mounted" to a client, and then used to decrypt files automatically +// /// @shared(id: uuid) // model Key { // id Int @id @default(autoincrement()) // // uuid to identify the key @@ -298,7 +288,7 @@ model Object { // } /// @shared(id: object, modelId: 4) -model MediaData { +model ExifData { id Int @id @default(autoincrement()) resolution Bytes? @@ -315,17 +305,164 @@ model MediaData { // (e.g. we can't get `MediaDate::Utc(2023-09-26T22:04:37+01:00)` from `1695758677` as we don't store the TZ) epoch_time BigInt? // time since unix epoch - // video-specific - // duration Int? - // fps Int? - // streams Int? - // video_codec String? // eg: "h264, h265, av1" - // audio_codec String? // eg: "opus" - object_id Int @unique object Object @relation(fields: [object_id], references: [id], onDelete: Cascade) - @@map("media_data") + @@map("exif_data") +} + +model FfmpegData { + id Int @id @default(autoincrement()) + + // Internal FFmpeg properties + formats String + bit_rate Bytes // Actually a i64 in the backend + duration Bytes? // Actually a i64 in the backend + start_time Bytes? // Actually a i64 in the backend + + chapters FfmpegMediaChapter[] + programs FfmpegMediaProgram[] + + // Metadata for search + title String? + creation_time DateTime? + date DateTime? + album_artist String? + disc String? + track String? + album String? + artist String? + metadata Bytes? + + object Object @relation(fields: [object_id], references: [id], onDelete: Cascade) + object_id Int @unique + + @@map("ffmpeg_data") +} + +model FfmpegMediaChapter { + chapter_id Int + + start Bytes // Actually a i64 in the backend + end Bytes // Actually a i64 in the backend + + time_base_den Int + time_base_num Int + + // Metadata for search + title String? + metadata Bytes? + + ffmpeg_data FfmpegData @relation(fields: [ffmpeg_data_id], references: [id], onDelete: Cascade) + ffmpeg_data_id Int + + @@id(name: "likeId", [ffmpeg_data_id, chapter_id]) + @@map("ffmpeg_media_chapter") +} + +model FfmpegMediaProgram { + program_id Int + + streams FfmpegMediaStream[] + + // Metadata for search + name String? + metadata Bytes? + + ffmpeg_data FfmpegData @relation(fields: [ffmpeg_data_id], references: [id], onDelete: Cascade) + ffmpeg_data_id Int + + @@id(name: "likeId", [ffmpeg_data_id, program_id]) + @@map("ffmpeg_media_program") +} + +model FfmpegMediaStream { + stream_id Int + + name String? + codec FfmpegMediaCodec? + aspect_ratio_num Int + aspect_ratio_den Int + frames_per_second_num Int + frames_per_second_den Int + time_base_real_den Int + time_base_real_num Int + dispositions String? + + // Metadata for search + title String? + encoder String? + language String? + duration Bytes? // Actually a i64 in the backend + metadata Bytes? + + program FfmpegMediaProgram @relation(fields: [ffmpeg_data_id, program_id], references: [ffmpeg_data_id, program_id], onDelete: Cascade) + program_id Int + ffmpeg_data_id Int + + @@id(name: "likeId", [ffmpeg_data_id, program_id, stream_id]) + @@map("ffmpeg_media_stream") +} + +model FfmpegMediaCodec { + id Int @id @default(autoincrement()) + + kind String? + sub_kind String? + tag String? + name String? + profile String? + bit_rate Int + + video_props FfmpegMediaVideoProps? + audio_props FfmpegMediaAudioProps? + + stream FfmpegMediaStream @relation(fields: [ffmpeg_data_id, program_id, stream_id], references: [ffmpeg_data_id, program_id, stream_id], onDelete: Cascade) + stream_id Int + program_id Int + ffmpeg_data_id Int + + @@unique([ffmpeg_data_id, program_id, stream_id]) + @@map("ffmpeg_media_codec") +} + +model FfmpegMediaVideoProps { + id Int @id @default(autoincrement()) + + pixel_format String? + color_range String? + bits_per_channel Int? + color_space String? + color_primaries String? + color_transfer String? + field_order String? + chroma_location String? + width Int + height Int + aspect_ratio_num Int? + aspect_ratio_Den Int? + properties String? + + codec FfmpegMediaCodec @relation(fields: [codec_id], references: [id], onDelete: Cascade) + codec_id Int @unique + + @@map("ffmpeg_media_video_props") +} + +model FfmpegMediaAudioProps { + id Int @id @default(autoincrement()) + + delay Int + padding Int + sample_rate Int? + sample_format String? + bit_per_sample Int? + channel_layout String? + + codec FfmpegMediaCodec @relation(fields: [codec_id], references: [id], onDelete: Cascade) + codec_id Int @unique + + @@map("ffmpeg_media_audio_props") } //// Tag //// @@ -478,20 +615,6 @@ model ObjectInAlbum { @@map("object_in_album") } -//// Comment //// - -// model Comment { -// id Int @id @default(autoincrement()) -// pub_id Bytes @unique -// content String -// date_created DateTime @default(now()) -// date_modified DateTime @default(now()) -// object_id Int? -// object Object? @relation(fields: [object_id], references: [id]) - -// @@map("comment") -// } - //// Indexer Rules //// model IndexerRule { diff --git a/core/src/api/ephemeral_files.rs b/core/src/api/ephemeral_files.rs index c614f4d48..b098144a1 100644 --- a/core/src/api/ephemeral_files.rs +++ b/core/src/api/ephemeral_files.rs @@ -1,19 +1,22 @@ use crate::{ - api::{files::create_file, utils::library}, + api::{ + files::{create_file, MediaData}, + utils::library, + }, invalidate_query, library::Library, object::{ fs::{error::FileSystemJobsError, find_available_filename_for_duplicate}, - media::media_data_extractor::{ - can_extract_media_data_for_image, extract_media_data, MediaDataError, - }, + media::exif_metadata_extractor::{can_extract_exif_data_for_image, extract_exif_data}, }, }; use sd_core_file_path_helper::IsolatedFilePathData; - -use sd_file_ext::extensions::ImageExtension; -use sd_media_metadata::MediaMetadata; +use sd_file_ext::{ + extensions::{Extension, ImageExtension}, + kind::ObjectKind, +}; +use sd_media_metadata::FFmpegMetadata; use sd_utils::error::FileIOError; use std::{ffi::OsStr, path::PathBuf, str::FromStr}; @@ -50,30 +53,56 @@ pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("getMediaData", { R.query(|_, full_path: PathBuf| async move { - let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) else { - return Ok(None); - }; + let kind: Option = Extension::resolve_conflicting(&full_path, false) + .await + .map(Into::into); + match kind { + Some(v) if v == ObjectKind::Image => { + let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) + else { + return Ok(None); + }; - // TODO(fogodev): change this when we have media data for audio and videos - let image_extension = ImageExtension::from_str(extension).map_err(|e| { - error!("Failed to parse image extension: {e:#?}"); - rspc::Error::new(ErrorCode::BadRequest, "Invalid image extension".to_string()) - })?; + let image_extension = ImageExtension::from_str(extension).map_err(|e| { + error!("Failed to parse image extension: {e:#?}"); + rspc::Error::new( + ErrorCode::BadRequest, + "Invalid image extension".to_string(), + ) + })?; - if !can_extract_media_data_for_image(&image_extension) { - return Ok(None); - } + if !can_extract_exif_data_for_image(&image_extension) { + return Ok(None); + } - match extract_media_data(full_path.clone()).await { - Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))), - Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( - _, - ))) => Ok(None), - Err(e) => Err(rspc::Error::with_cause( - ErrorCode::InternalServerError, - "Failed to extract media data".to_string(), - e, - )), + let exif_data = extract_exif_data(full_path) + .await + .map_err(|e| { + rspc::Error::with_cause( + ErrorCode::InternalServerError, + "Failed to extract media data".to_string(), + e, + ) + })? + .map(MediaData::Exif); + + Ok(exif_data) + } + Some(v) if v == ObjectKind::Audio || v == ObjectKind::Video => { + let ffmpeg_data = MediaData::FFmpeg( + FFmpegMetadata::from_path(full_path).await.map_err(|e| { + error!("{e:#?}"); + rspc::Error::with_cause( + ErrorCode::InternalServerError, + e.to_string(), + e, + ) + })?, + ); + + Ok(Some(ffmpeg_data)) + } + _ => Ok(None), // No media data } }) }) diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 977fb5c91..d83d1e2bd 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -9,7 +9,7 @@ use crate::{ old_copy::OldFileCopierJobInit, old_cut::OldFileCutterJobInit, old_delete::OldFileDeleterJobInit, old_erase::OldFileEraserJobInit, }, - media::media_data_image_from_prisma_data, + media::{exif_media_data_from_prisma_data, ffmpeg_data_from_prisma_data}, }, old_job::Job, }; @@ -17,11 +17,12 @@ use crate::{ use sd_core_file_path_helper::{FilePathError, IsolatedFilePathData}; use sd_core_prisma_helpers::{ file_path_to_isolate, file_path_to_isolate_with_id, object_with_file_paths, + object_with_media_data, }; use sd_file_ext::kind::ObjectKind; use sd_images::ConvertibleExtension; -use sd_media_metadata::MediaMetadata; +use sd_media_metadata::{ExifMetadata, FFmpegMetadata}; use sd_prisma::{ prisma::{file_path, location, object}, prisma_sync, @@ -59,6 +60,12 @@ enum FileCreateContextTypes { Text, } +#[derive(Serialize, Type)] +pub(crate) enum MediaData { + Exif(ExifMetadata), + FFmpeg(FFmpegMetadata), +} + pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("get", { @@ -114,17 +121,23 @@ pub(crate) fn mount() -> AlphaRouter { .db .object() .find_unique(object::id::equals(args)) - .select(object::select!({ id kind media_data })) + .include(object_with_media_data::include()) .exec() .await? .and_then(|obj| { Some(match obj.kind { - Some(v) if v == ObjectKind::Image as i32 => { - MediaMetadata::Image(Box::new( - media_data_image_from_prisma_data(obj.media_data?).ok()?, + Some(v) if v == ObjectKind::Image as i32 => MediaData::Exif( + exif_media_data_from_prisma_data(obj.exif_data?), + ), + Some(v) + if v == ObjectKind::Audio as i32 + || v == ObjectKind::Video as i32 => + { + MediaData::FFmpeg(ffmpeg_data_from_prisma_data( + obj.ffmpeg_data?, )) } - _ => return None, // TODO(brxken128): audio and video + _ => return None, // No media data }) }) .ok_or_else(|| { diff --git a/core/src/api/search/media_data.rs b/core/src/api/search/exif_data.rs similarity index 71% rename from core/src/api/search/media_data.rs rename to core/src/api/search/exif_data.rs index 52a84b0d4..61b8f6cd8 100644 --- a/core/src/api/search/media_data.rs +++ b/core/src/api/search/exif_data.rs @@ -1,4 +1,4 @@ -use sd_prisma::prisma::{self, media_data}; +use sd_prisma::prisma::{self, exif_data}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -7,11 +7,11 @@ use super::utils::*; #[derive(Serialize, Deserialize, Type, Debug, Clone)] #[serde(rename_all = "camelCase", tag = "field", content = "value")] -pub enum MediaDataOrder { +pub enum ExifDataOrder { EpochTime(SortOrder), } -impl MediaDataOrder { +impl ExifDataOrder { pub fn get_sort_order(&self) -> prisma::SortOrder { (*match self { Self::EpochTime(v) => v, @@ -19,9 +19,9 @@ impl MediaDataOrder { .into() } - pub fn into_param(self) -> media_data::OrderByWithRelationParam { + pub fn into_param(self) -> exif_data::OrderByWithRelationParam { let dir = self.get_sort_order(); - use media_data::*; + use exif_data::*; match self { Self::EpochTime(_) => epoch_time::order(dir), } diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index 1547d0b27..203fc2f33 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -19,8 +19,8 @@ use rspc::{alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; use specta::Type; +pub mod exif_data; pub mod file_path; -pub mod media_data; pub mod object; pub mod saved; mod utils; diff --git a/core/src/api/search/object.rs b/core/src/api/search/object.rs index 247634e2d..5804b3917 100644 --- a/core/src/api/search/object.rs +++ b/core/src/api/search/object.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use super::{ - media_data::*, + exif_data::*, utils::{self, *}, }; @@ -61,7 +61,7 @@ impl ObjectCursor { pub enum ObjectOrder { DateAccessed(SortOrder), Kind(SortOrder), - MediaData(Box), + MediaData(Box), } impl ObjectOrder { @@ -81,7 +81,7 @@ impl ObjectOrder { match self { Self::DateAccessed(_) => date_accessed::order(dir), Self::Kind(_) => kind::order(dir), - Self::MediaData(v) => media_data::order(vec![v.into_param()]), + Self::MediaData(v) => exif_data::order(vec![v.into_param()]), } } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 17213e723..49762e857 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "256"] #![warn(clippy::unwrap_used, clippy::panic)] use crate::{ diff --git a/core/src/location/manager/watcher/utils.rs b/core/src/location/manager/watcher/utils.rs index 0df4d10a0..98444008c 100644 --- a/core/src/location/manager/watcher/utils.rs +++ b/core/src/location/manager/watcher/utils.rs @@ -8,8 +8,12 @@ use crate::{ }, object::{ media::{ - media_data_extractor::{can_extract_media_data_for_image, extract_media_data}, - media_data_image_to_query_params, + exif_data_image_to_query_params, + exif_metadata_extractor::{can_extract_exif_data_for_image, extract_exif_data}, + ffmpeg_metadata_extractor::{ + can_extract_ffmpeg_data_for_audio, can_extract_ffmpeg_data_for_video, + extract_ffmpeg_data, save_ffmpeg_data, + }, old_thumbnail::get_indexed_thumbnail_path, }, old_file_identifier::FileMetadata, @@ -26,9 +30,12 @@ use sd_core_file_path_helper::{ }; use sd_core_prisma_helpers::file_path_with_object; -use sd_file_ext::{extensions::ImageExtension, kind::ObjectKind}; +use sd_file_ext::{ + extensions::{AudioExtension, ImageExtension, VideoExtension}, + kind::ObjectKind, +}; use sd_prisma::{ - prisma::{file_path, location, media_data, object}, + prisma::{exif_data, file_path, location, object}, prisma_sync, }; use sd_sync::OperationFactory; @@ -312,63 +319,100 @@ async fn inner_create_file( ) .await?; - if !extension.is_empty() && matches!(kind, ObjectKind::Image | ObjectKind::Video) { + if !extension.is_empty() + && matches!( + kind, + ObjectKind::Image | ObjectKind::Video | ObjectKind::Audio + ) { // Running in a detached task as thumbnail generation can take a while and we don't want to block the watcher + if matches!(kind, ObjectKind::Image | ObjectKind::Video) { + if let Some(cas_id) = cas_id { + spawn({ + let extension = extension.clone(); + let path = path.to_path_buf(); + let node = node.clone(); + let library_id = *library_id; - if let Some(cas_id) = cas_id { - spawn({ - let extension = extension.clone(); - let path = path.to_path_buf(); - let node = node.clone(); - let library_id = *library_id; - - async move { - if let Err(e) = node - .thumbnailer - .generate_single_indexed_thumbnail(&extension, cas_id, path, library_id) - .await - { - error!("Failed to generate thumbnail in the watcher: {e:#?}"); + async move { + if let Err(e) = node + .thumbnailer + .generate_single_indexed_thumbnail(&extension, cas_id, path, library_id) + .await + { + error!("Failed to generate thumbnail in the watcher: {e:#?}"); + } } - } - }); + }); + } } - // TODO: Currently we only extract media data for images, remove this if later - if matches!(kind, ObjectKind::Image) { - if let Ok(image_extension) = ImageExtension::from_str(&extension) { - if can_extract_media_data_for_image(&image_extension) { - if let Ok(media_data) = extract_media_data(path) - .await - .map_err(|e| error!("Failed to extract media data: {e:#?}")) - { - let (sync_params, db_params) = media_data_image_to_query_params(media_data); + match kind { + ObjectKind::Image => { + if let Ok(image_extension) = ImageExtension::from_str(&extension) { + if can_extract_exif_data_for_image(&image_extension) { + if let Ok(Some(exif_data)) = extract_exif_data(path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + let (sync_params, db_params) = + exif_data_image_to_query_params(exif_data); - sync.write_ops( - db, - ( - sync.shared_create( - prisma_sync::media_data::SyncId { - object: prisma_sync::object::SyncId { - pub_id: object_pub_id.clone(), + sync.write_ops( + db, + ( + sync.shared_create( + prisma_sync::exif_data::SyncId { + object: prisma_sync::object::SyncId { + pub_id: object_pub_id.clone(), + }, }, - }, - sync_params, - ), - db.media_data().upsert( - media_data::object_id::equals(object_id), - media_data::create( - object::id::equals(object_id), - db_params.clone(), + sync_params, + ), + db.exif_data().upsert( + exif_data::object_id::equals(object_id), + exif_data::create( + object::id::equals(object_id), + db_params.clone(), + ), + db_params, ), - db_params, ), - ), - ) - .await?; + ) + .await?; + } } } } + + ObjectKind::Audio => { + if let Ok(audio_extension) = AudioExtension::from_str(&extension) { + if can_extract_ffmpeg_data_for_audio(&audio_extension) { + if let Ok(ffmpeg_data) = extract_ffmpeg_data(path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + save_ffmpeg_data([(ffmpeg_data, object_id)], db).await?; + } + } + } + } + + ObjectKind::Video => { + if let Ok(video_extension) = VideoExtension::from_str(&extension) { + if can_extract_ffmpeg_data_for_video(&video_extension) { + if let Ok(ffmpeg_data) = extract_ffmpeg_data(path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + save_ffmpeg_data([(ffmpeg_data, object_id)], db).await?; + } + } + } + } + + _ => { + // Do nothing + } } } @@ -680,43 +724,74 @@ async fn inner_update_file( } } - // TODO: Change this if to include ObjectKind::Video in the future - if let Some(ext) = &file_path.extension { - if let Ok(image_extension) = ImageExtension::from_str(ext) { - if can_extract_media_data_for_image(&image_extension) - && matches!(kind, ObjectKind::Image) - { - if let Ok(media_data) = extract_media_data(full_path) - .await - .map_err(|e| error!("Failed to extract media data: {e:#?}")) - { - let (sync_params, db_params) = - media_data_image_to_query_params(media_data); + if let Some(extension) = &file_path.extension { + match kind { + ObjectKind::Image => { + if let Ok(image_extension) = ImageExtension::from_str(extension) { + if can_extract_exif_data_for_image(&image_extension) { + if let Ok(Some(exif_data)) = extract_exif_data(full_path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + let (sync_params, db_params) = + exif_data_image_to_query_params(exif_data); - sync.write_ops( - db, - ( - sync.shared_create( - prisma_sync::media_data::SyncId { - object: prisma_sync::object::SyncId { - pub_id: object.pub_id.clone(), - }, - }, - sync_params, - ), - db.media_data().upsert( - media_data::object_id::equals(object.id), - media_data::create( - object::id::equals(object.id), - db_params.clone(), + sync.write_ops( + db, + ( + sync.shared_create( + prisma_sync::exif_data::SyncId { + object: prisma_sync::object::SyncId { + pub_id: object.pub_id.clone(), + }, + }, + sync_params, + ), + db.exif_data().upsert( + exif_data::object_id::equals(object.id), + exif_data::create( + object::id::equals(object.id), + db_params.clone(), + ), + db_params, + ), ), - db_params, - ), - ), - ) - .await?; + ) + .await?; + } + } } } + + ObjectKind::Audio => { + if let Ok(audio_extension) = AudioExtension::from_str(extension) { + if can_extract_ffmpeg_data_for_audio(&audio_extension) { + if let Ok(ffmpeg_data) = extract_ffmpeg_data(full_path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + save_ffmpeg_data([(ffmpeg_data, object.id)], db).await?; + } + } + } + } + + ObjectKind::Video => { + if let Ok(video_extension) = VideoExtension::from_str(extension) { + if can_extract_ffmpeg_data_for_video(&video_extension) { + if let Ok(ffmpeg_data) = extract_ffmpeg_data(full_path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + save_ffmpeg_data([(ffmpeg_data, object.id)], db).await?; + } + } + } + } + + _ => { + // Do nothing + } } } } diff --git a/core/src/location/non_indexed.rs b/core/src/location/non_indexed.rs index c4ace38cc..6c69cc7b3 100644 --- a/core/src/location/non_indexed.rs +++ b/core/src/location/non_indexed.rs @@ -198,7 +198,7 @@ pub async fn walk( } }; - let thumbnail_key = if should_generate_thumbnail { + let (thumbnail_key, has_created_thumbnail) = if should_generate_thumbnail { if let Ok(cas_id) = generate_cas_id(&path, entry.metadata.len()) .await @@ -221,12 +221,18 @@ pub async fn walk( )); } - Some(get_ephemeral_thumb_key(&cas_id)) + ( + Some(get_ephemeral_thumb_key(&cas_id)), + library + .thumbnail_exists(&node, &cas_id) + .await + .map_err(NonIndexedLocationError::from)?, + ) } else { - None + (None, false) } } else { - None + (None, false) }; tx.send(Ok(ExplorerItem::NonIndexedPath { @@ -242,7 +248,7 @@ pub async fn walk( date_modified: entry.metadata.modified_or_now().into(), size_in_bytes_bytes: entry.metadata.len().to_be_bytes().to_vec(), }, - has_created_thumbnail: false, + has_created_thumbnail, })) .await?; } diff --git a/core/src/object/media/media_data_extractor.rs b/core/src/object/media/exif_metadata_extractor.rs similarity index 55% rename from core/src/object/media/media_data_extractor.rs rename to core/src/object/media/exif_metadata_extractor.rs index 7fd7b2b3c..96a815c80 100644 --- a/core/src/object/media/media_data_extractor.rs +++ b/core/src/object/media/exif_metadata_extractor.rs @@ -4,9 +4,8 @@ use sd_core_file_path_helper::IsolatedFilePathData; use sd_core_prisma_helpers::file_path_for_media_processor; use sd_file_ext::extensions::{Extension, ImageExtension, ALL_IMAGE_EXTENSIONS}; -use sd_media_metadata::ImageMetadata; -use sd_prisma::prisma::{location, media_data, PrismaClient}; -use sd_utils::error::FileIOError; +use sd_media_metadata::ExifMetadata; +use sd_prisma::prisma::{exif_data, location, PrismaClient}; use std::{collections::HashSet, path::Path}; @@ -14,26 +13,21 @@ use futures_concurrency::future::Join; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use thiserror::Error; -use tokio::task::spawn_blocking; use tracing::error; -use super::media_data_image_to_query; +use super::exif_data_image_to_query; #[derive(Error, Debug)] -pub enum MediaDataError { +pub enum ExifDataError { // Internal errors #[error("database error: {0}")] Database(#[from] prisma_client_rust::QueryError), #[error(transparent)] - FileIO(#[from] FileIOError), - #[error(transparent)] MediaData(#[from] sd_media_metadata::Error), - #[error("failed to join tokio task: {0}")] - TokioJoinHandle(#[from] tokio::task::JoinError), } #[derive(Serialize, Deserialize, Default, Debug)] -pub struct OldMediaDataExtractorMetadata { +pub struct OldExifDataExtractorMetadata { pub extracted: u32, pub skipped: u32, } @@ -42,12 +36,12 @@ pub(super) static FILTERED_IMAGE_EXTENSIONS: Lazy> = Lazy::new(|| ALL_IMAGE_EXTENSIONS .iter() .cloned() - .filter(can_extract_media_data_for_image) + .filter(can_extract_exif_data_for_image) .map(Extension::Image) .collect() }); -pub const fn can_extract_media_data_for_image(image_extension: &ImageExtension) -> bool { +pub const fn can_extract_exif_data_for_image(image_extension: &ImageExtension) -> bool { use ImageExtension::*; matches!( image_extension, @@ -55,13 +49,10 @@ pub const fn can_extract_media_data_for_image(image_extension: &ImageExtension) ) } -pub async fn extract_media_data(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - - // Running in a separated blocking thread due to MediaData blocking behavior (due to sync exif lib) - spawn_blocking(|| ImageMetadata::from_path(path)) - .await? - .map_err(Into::into) +pub async fn extract_exif_data( + path: impl AsRef + Send, +) -> Result, ExifDataError> { + ExifMetadata::from_path(path).await.map_err(Into::into) } pub async fn process( @@ -70,46 +61,46 @@ pub async fn process( location_path: impl AsRef, db: &PrismaClient, ctx_update_fn: &impl Fn(usize), -) -> Result<(OldMediaDataExtractorMetadata, JobRunErrors), MediaDataError> { - let mut run_metadata = OldMediaDataExtractorMetadata::default(); +) -> Result<(OldExifDataExtractorMetadata, JobRunErrors), ExifDataError> { + let mut run_metadata = OldExifDataExtractorMetadata::default(); if files_paths.is_empty() { return Ok((run_metadata, JobRunErrors::default())); } let location_path = location_path.as_ref(); - let objects_already_with_media_data = db - .media_data() - .find_many(vec![media_data::object_id::in_vec( + let objects_already_with_exif_data = db + .exif_data() + .find_many(vec![exif_data::object_id::in_vec( files_paths .iter() .filter_map(|file_path| file_path.object_id) .collect(), )]) - .select(media_data::select!({ object_id })) + .select(exif_data::select!({ object_id })) .exec() .await?; - if files_paths.len() == objects_already_with_media_data.len() { + if files_paths.len() == objects_already_with_exif_data.len() { // All files already have media data, skipping run_metadata.skipped = files_paths.len() as u32; return Ok((run_metadata, JobRunErrors::default())); } - let objects_already_with_media_data = objects_already_with_media_data + let objects_already_with_exif_data = objects_already_with_exif_data .into_iter() - .map(|media_data| media_data.object_id) + .map(|exif_data| exif_data.object_id) .collect::>(); - run_metadata.skipped = objects_already_with_media_data.len() as u32; + run_metadata.skipped = objects_already_with_exif_data.len() as u32; - let (media_datas, errors) = { - let maybe_media_data = files_paths + let (exif_datas, errors) = { + let maybe_exif_data = files_paths .iter() .enumerate() .filter_map(|(idx, file_path)| { file_path.object_id.and_then(|object_id| { - (!objects_already_with_media_data.contains(&object_id)) + (!objects_already_with_exif_data.contains(&object_id)) .then_some((idx, file_path, object_id)) }) }) @@ -120,7 +111,7 @@ pub async fn process( .map(|iso_file_path| (idx, location_path.join(iso_file_path), object_id)) }) .map(|(idx, path, object_id)| async move { - let res = extract_media_data(&path).await; + let res = extract_exif_data(&path).await; ctx_update_fn(idx + 1); (res, path, object_id) }) @@ -128,37 +119,31 @@ pub async fn process( .join() .await; - let total_media_data = maybe_media_data.len(); + let total_exif_data = maybe_exif_data.len(); - maybe_media_data.into_iter().fold( - // In the good case, all media data were extracted - (Vec::with_capacity(total_media_data), Vec::new()), - |(mut media_datas, mut errors), (maybe_media_data, path, object_id)| { - match maybe_media_data { - Ok(media_data) => media_datas.push((media_data, object_id)), - Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( - _, - ))) => { + maybe_exif_data.into_iter().fold( + // In the good case, all exif data were extracted + (Vec::with_capacity(total_exif_data), Vec::new()), + |(mut exif_datas, mut errors), (maybe_exif_data, path, object_id)| { + match maybe_exif_data { + Ok(Some(exif_data)) => exif_datas.push((exif_data, object_id)), + Ok(None) => { // No exif data on path, skipping run_metadata.skipped += 1; } Err(e) => errors.push((e, path)), } - (media_datas, errors) + (exif_datas, errors) }, ) }; let created = db - .media_data() + .exif_data() .create_many( - media_datas + exif_datas .into_iter() - .filter_map(|(media_data, object_id)| { - media_data_image_to_query(media_data, object_id) - .map_err(|e| error!("{e:#?}")) - .ok() - }) + .map(|(exif_data, object_id)| exif_data_image_to_query(exif_data, object_id)) .collect(), ) .skip_duplicates() diff --git a/core/src/object/media/ffmpeg_metadata_extractor.rs b/core/src/object/media/ffmpeg_metadata_extractor.rs new file mode 100644 index 000000000..14e5fcc4d --- /dev/null +++ b/core/src/object/media/ffmpeg_metadata_extractor.rs @@ -0,0 +1,660 @@ +use crate::old_job::JobRunErrors; + +use prisma_client_rust::QueryError; +use sd_core_file_path_helper::IsolatedFilePathData; +use sd_core_prisma_helpers::file_path_for_media_processor; + +use sd_file_ext::extensions::{ + AudioExtension, Extension, VideoExtension, ALL_AUDIO_EXTENSIONS, ALL_VIDEO_EXTENSIONS, +}; +use sd_media_metadata::{ + ffmpeg::{ + audio_props::AudioProps, + chapter::Chapter, + codec::{Codec, Props}, + metadata::Metadata, + program::Program, + stream::Stream, + video_props::VideoProps, + }, + FFmpegMetadata, +}; +use sd_prisma::prisma::{ + ffmpeg_data, ffmpeg_media_audio_props, ffmpeg_media_chapter, ffmpeg_media_codec, + ffmpeg_media_program, ffmpeg_media_stream, ffmpeg_media_video_props, location, object, + PrismaClient, +}; +use sd_utils::db::ffmpeg_data_field_to_db; + +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; + +use futures_concurrency::future::{Join, TryJoin}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::error; + +#[derive(Error, Debug)] +pub enum FFmpegDataError { + // Internal errors + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), + #[error(transparent)] + MediaData(#[from] sd_media_metadata::Error), +} + +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct OldFFmpegDataExtractorMetadata { + pub extracted: u32, + pub skipped: u32, +} + +pub(super) static FILTERED_AUDIO_AND_VIDEO_EXTENSIONS: Lazy> = Lazy::new(|| { + ALL_AUDIO_EXTENSIONS + .iter() + .copied() + .filter(can_extract_ffmpeg_data_for_audio) + .map(Extension::Audio) + .chain( + ALL_VIDEO_EXTENSIONS + .iter() + .copied() + .filter(can_extract_ffmpeg_data_for_video) + .map(Extension::Video), + ) + .collect() +}); + +pub const fn can_extract_ffmpeg_data_for_audio(audio_extension: &AudioExtension) -> bool { + use AudioExtension::*; + // TODO: Remove from here any extension which ffmpeg can't extract metadata from + matches!( + audio_extension, + Mp3 | Mp2 + | M4a | Wav | Aiff + | Aif | Flac | Ogg + | Oga | Opus | Wma + | Amr | Aac | Wv + | Voc | Tta | Loas + | Caf | Aptx | Adts + | Ast | Mid + ) +} + +pub const fn can_extract_ffmpeg_data_for_video(video_extension: &VideoExtension) -> bool { + use VideoExtension::*; + // TODO: Remove from here any extension which ffmpeg can't extract metadata from + matches!( + video_extension, + Avi | Avifs + | Qt | Mov | Swf + | Mjpeg | Ts | Mts + | Mpeg | Mxf | M2v + | Mpg | Mpe | M2ts + | Flv | Wm | _3gp + | M4v | Wmv | Asf + | Mp4 | Webm | Mkv + | Vob | Ogv | Wtv + | Hevc | F4v + ) +} + +pub async fn extract_ffmpeg_data( + path: impl AsRef + Send, +) -> Result { + FFmpegMetadata::from_path(path).await.map_err(Into::into) +} + +pub async fn process( + files_paths: &[file_path_for_media_processor::Data], + location_id: location::id::Type, + location_path: impl AsRef + Send, + db: &PrismaClient, + ctx_update_fn: &impl Fn(usize), +) -> Result<(OldFFmpegDataExtractorMetadata, JobRunErrors), FFmpegDataError> { + let mut run_metadata = OldFFmpegDataExtractorMetadata::default(); + if files_paths.is_empty() { + return Ok((run_metadata, JobRunErrors::default())); + } + + let location_path = location_path.as_ref(); + + let objects_already_with_ffmpeg_data = db + .ffmpeg_data() + .find_many(vec![ffmpeg_data::object_id::in_vec( + files_paths + .iter() + .filter_map(|file_path| file_path.object_id) + .collect(), + )]) + .select(ffmpeg_data::select!({ object_id })) + .exec() + .await?; + + if files_paths.len() == objects_already_with_ffmpeg_data.len() { + // All files already have media data, skipping + run_metadata.skipped = files_paths.len() as u32; + return Ok((run_metadata, JobRunErrors::default())); + } + + let objects_already_with_ffmpeg_data = objects_already_with_ffmpeg_data + .into_iter() + .map(|ffmpeg_data| ffmpeg_data.object_id) + .collect::>(); + + run_metadata.skipped = objects_already_with_ffmpeg_data.len() as u32; + + let mut errors = vec![]; + + let ffmpeg_datas = files_paths + .iter() + .enumerate() + .filter_map(|(idx, file_path)| { + file_path.object_id.and_then(|object_id| { + (!objects_already_with_ffmpeg_data.contains(&object_id)) + .then_some((idx, file_path, object_id)) + }) + }) + .filter_map(|(idx, file_path, object_id)| { + IsolatedFilePathData::try_from((location_id, file_path)) + .map_err(|e| error!("{e:#?}")) + .ok() + .map(|iso_file_path| (idx, location_path.join(iso_file_path), object_id)) + }) + .map(|(idx, path, object_id)| async move { + let res = extract_ffmpeg_data(&path).await; + ctx_update_fn(idx + 1); + (res, path, object_id) + }) + .collect::>() + .join() + .await + .into_iter() + .filter_map(|(res, path, object_id)| { + res.map(|ffmpeg_data| (ffmpeg_data, object_id)) + .map_err(|e| errors.push((e, path))) + .ok() + }) + .collect::>(); + + let created = save_ffmpeg_data(ffmpeg_datas, db).await?; + + run_metadata.extracted = created as u32; + run_metadata.skipped += errors.len() as u32; + + Ok(( + run_metadata, + errors + .into_iter() + .map(|(e, path)| format!("Couldn't process file: \"{}\"; Error: {e}", path.display())) + .collect::>() + .into(), + )) +} + +pub async fn save_ffmpeg_data( + ffmpeg_datas: impl IntoIterator, + db: &PrismaClient, +) -> Result { + ffmpeg_datas + .into_iter() + .map( + move |( + FFmpegMetadata { + formats, + duration, + start_time, + bit_rate, + chapters, + programs, + metadata, + }, + object_id, + )| { + db._transaction() + .with_timeout(30 * 1000) + .run(move |db| async move { + let data_id = create_ffmpeg_data( + formats, bit_rate, duration, start_time, metadata, object_id, &db, + ) + .await?; + + create_ffmpeg_chapters(data_id, chapters, &db).await?; + + let streams = create_ffmpeg_programs(data_id, programs, &db).await?; + + let codecs = create_ffmpeg_streams(data_id, streams, &db).await?; + + let (audio_props, video_props) = + create_ffmpeg_codecs(data_id, codecs, &db).await?; + + ( + create_ffmpeg_audio_props(audio_props, &db), + create_ffmpeg_video_props(video_props, &db), + ) + .try_join() + .await + .map(|_| ()) + }) + }, + ) + .collect::>() + .try_join() + .await + .map(|created| created.len() as u32) +} + +async fn create_ffmpeg_data( + formats: Vec, + bit_rate: (u32, u32), + duration: Option<(u32, u32)>, + start_time: Option<(u32, u32)>, + metadata: Metadata, + object_id: i32, + db: &PrismaClient, +) -> Result { + db.ffmpeg_data() + .create( + formats.join(","), + ffmpeg_data_field_to_db((bit_rate.0 as i64) << 32 | bit_rate.1 as i64), + object::id::equals(object_id), + vec![ + ffmpeg_data::duration::set( + duration.map(|(a, b)| ffmpeg_data_field_to_db((a as i64) << 32 | b as i64)), + ), + ffmpeg_data::start_time::set( + start_time.map(|(a, b)| ffmpeg_data_field_to_db((a as i64) << 32 | b as i64)), + ), + ffmpeg_data::metadata::set( + serde_json::to_vec(&metadata) + .map_err(|err| { + error!("Error reading FFmpegData metadata: {err:#?}"); + err + }) + .ok(), + ), + ], + ) + .select(ffmpeg_data::select!({ id })) + .exec() + .await + .map(|data| data.id) +} + +async fn create_ffmpeg_chapters( + ffmpeg_data_id: ffmpeg_data::id::Type, + chapters: Vec, + db: &PrismaClient, +) -> Result<(), QueryError> { + db.ffmpeg_media_chapter() + .create_many( + chapters + .into_iter() + .map( + |Chapter { + id: chapter_id, + start: (start_high, start_low), + end: (end_high, end_low), + time_base_den, + time_base_num, + metadata, + }| ffmpeg_media_chapter::CreateUnchecked { + chapter_id, + start: ffmpeg_data_field_to_db( + (start_high as i64) << 32 | start_low as i64, + ), + end: ffmpeg_data_field_to_db((end_high as i64) << 32 | end_low as i64), + time_base_den, + time_base_num, + ffmpeg_data_id, + _params: vec![ffmpeg_media_chapter::metadata::set( + serde_json::to_vec(&metadata) + .map_err(|err| { + error!("Error reading FFmpegMediaChapter metadata: {err:#?}"); + err + }) + .ok(), + )], + }, + ) + .collect(), + ) + .exec() + .await + .map(|_| ()) +} + +async fn create_ffmpeg_programs( + data_id: i32, + programs: Vec, + db: &PrismaClient, +) -> Result)>, QueryError> { + let (creates, streams_by_program_id) = + programs + .into_iter() + .map( + |Program { + id: program_id, + name, + metadata, + streams, + }| { + ( + ffmpeg_media_program::CreateUnchecked { + program_id, + ffmpeg_data_id: data_id, + _params: vec![ + ffmpeg_media_program::name::set(name.clone()), + ffmpeg_media_program::metadata::set( + serde_json::to_vec(&metadata) + .map_err(|err| { + error!("Error reading FFmpegMediaProgram metadata: {err:#?}"); + err + }) + .ok(), + ), + ], + }, + (program_id, streams), + ) + }, + ) + .unzip::<_, _, Vec<_>, Vec<_>>(); + + db.ffmpeg_media_program() + .create_many(creates) + .exec() + .await + .map(|_| streams_by_program_id) +} + +async fn create_ffmpeg_streams( + ffmpeg_data_id: ffmpeg_data::id::Type, + streams: Vec<(ffmpeg_media_program::program_id::Type, Vec)>, + db: &PrismaClient, +) -> Result< + Vec<( + ffmpeg_media_program::program_id::Type, + ffmpeg_media_stream::stream_id::Type, + Codec, + )>, + QueryError, +> { + let (creates, maybe_codecs) = streams + .into_iter() + .flat_map(|(program_id, streams)| { + streams.into_iter().map( + move |Stream { + id: stream_id, + name, + codec: maybe_codec, + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + dispositions, + metadata, + }| { + ( + ffmpeg_media_stream::CreateUnchecked { + stream_id, + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + program_id, + ffmpeg_data_id, + _params: vec![ + ffmpeg_media_stream::name::set(name), + ffmpeg_media_stream::dispositions::set( + (!dispositions.is_empty()).then_some(dispositions.join(",")), + ), + ffmpeg_media_stream::title::set(metadata.title.clone()), + ffmpeg_media_stream::encoder::set(metadata.encoder.clone()), + ffmpeg_media_stream::language::set(metadata.language.clone()), + ffmpeg_media_stream::metadata::set( + serde_json::to_vec(&metadata) + .map_err(|err| { + error!("Error reading FFmpegMediaStream metadata: {err:#?}"); + err + }) + .ok(), + ), + ], + }, + maybe_codec.map(|codec| (program_id, stream_id, codec)), + ) + }, + ) + }) + .unzip::<_, _, Vec<_>, Vec<_>>(); + + db.ffmpeg_media_stream() + .create_many(creates) + .exec() + .await + .map(|_| maybe_codecs.into_iter().flatten().collect()) +} + +async fn create_ffmpeg_codecs( + ffmpeg_data_id: ffmpeg_data::id::Type, + codecs: Vec<( + ffmpeg_media_program::program_id::Type, + ffmpeg_media_stream::stream_id::Type, + Codec, + )>, + db: &PrismaClient, +) -> Result< + ( + Vec<(ffmpeg_media_codec::id::Type, AudioProps)>, + Vec<(ffmpeg_media_codec::id::Type, VideoProps)>, + ), + QueryError, +> { + let expected_creates = codecs.len(); + + let (creates, mut audio_props, mut video_props) = codecs.into_iter().enumerate().fold( + ( + Vec::with_capacity(expected_creates), + HashMap::with_capacity(expected_creates), + HashMap::with_capacity(expected_creates), + ), + |(mut creates, mut audio_props, mut video_props), + ( + idx, + ( + program_id, + stream_id, + Codec { + kind, + sub_kind, + tag, + name, + profile, + bit_rate, + props: maybe_props, + }, + ), + )| { + creates.push(ffmpeg_media_codec::CreateUnchecked { + bit_rate, + stream_id, + program_id, + ffmpeg_data_id, + _params: vec![ + ffmpeg_media_codec::kind::set(kind), + ffmpeg_media_codec::sub_kind::set(sub_kind), + ffmpeg_media_codec::tag::set(tag), + ffmpeg_media_codec::name::set(name), + ffmpeg_media_codec::profile::set(profile), + ], + }); + + if let Some(props) = maybe_props { + match props { + Props::Audio(props) => { + audio_props.insert(idx, props); + } + Props::Video(props) => { + video_props.insert(idx, props); + } + Props::Subtitle(_) => { + // We don't care about subtitles props for now :D + } + } + } + + (creates, audio_props, video_props) + }, + ); + + let created_ids = creates + .into_iter() + .map( + |ffmpeg_media_codec::CreateUnchecked { + bit_rate, + stream_id, + program_id, + ffmpeg_data_id, + _params, + }| { + db.ffmpeg_media_codec() + .create_unchecked(bit_rate, stream_id, program_id, ffmpeg_data_id, _params) + .select(ffmpeg_media_codec::select!({ id })) + .exec() + }, + ) + .collect::>() + .try_join() + .await?; + + assert_eq!( + created_ids.len(), + expected_creates, + "Not all codecs were created and our invariant is broken!" + ); + + debug_assert!( + created_ids + .windows(2) + .all(|window| window[0].id < window[1].id), + "Codecs were created in a different order than we expected, our invariant is broken!" + ); + + Ok(created_ids.into_iter().enumerate().fold( + ( + Vec::with_capacity(audio_props.len()), + Vec::with_capacity(video_props.len()), + ), + |(mut a_props, mut v_props), (idx, codec_data)| { + if let Some(audio_props) = audio_props.remove(&idx) { + a_props.push((codec_data.id, audio_props)); + } else if let Some(video_props) = video_props.remove(&idx) { + v_props.push((codec_data.id, video_props)); + } + + (a_props, v_props) + }, + )) +} + +async fn create_ffmpeg_audio_props( + audio_props: Vec<(ffmpeg_media_codec::id::Type, AudioProps)>, + db: &PrismaClient, +) -> Result<(), QueryError> { + db.ffmpeg_media_audio_props() + .create_many( + audio_props + .into_iter() + .map( + |( + codec_id, + AudioProps { + delay, + padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + }, + )| ffmpeg_media_audio_props::CreateUnchecked { + delay, + padding, + codec_id, + _params: vec![ + ffmpeg_media_audio_props::sample_rate::set(sample_rate), + ffmpeg_media_audio_props::sample_format::set(sample_format), + ffmpeg_media_audio_props::bit_per_sample::set(bit_per_sample), + ffmpeg_media_audio_props::channel_layout::set(channel_layout), + ], + }, + ) + .collect(), + ) + .exec() + .await + .map(|_| ()) +} + +async fn create_ffmpeg_video_props( + video_props: Vec<(ffmpeg_media_codec::id::Type, VideoProps)>, + db: &PrismaClient, +) -> Result<(), QueryError> { + db.ffmpeg_media_video_props() + .create_many( + video_props + .into_iter() + .map( + |( + codec_id, + VideoProps { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties, + }, + )| { + ffmpeg_media_video_props::CreateUnchecked { + width, + height, + codec_id, + _params: vec![ + ffmpeg_media_video_props::pixel_format::set(pixel_format), + ffmpeg_media_video_props::color_range::set(color_range), + ffmpeg_media_video_props::bits_per_channel::set(bits_per_channel), + ffmpeg_media_video_props::color_space::set(color_space), + ffmpeg_media_video_props::color_primaries::set(color_primaries), + ffmpeg_media_video_props::color_transfer::set(color_transfer), + ffmpeg_media_video_props::field_order::set(field_order), + ffmpeg_media_video_props::chroma_location::set(chroma_location), + ffmpeg_media_video_props::aspect_ratio_num::set(aspect_ratio_num), + ffmpeg_media_video_props::aspect_ratio_den::set(aspect_ratio_den), + ffmpeg_media_video_props::properties::set(Some( + properties.join(","), + )), + ], + } + }, + ) + .collect(), + ) + .exec() + .await + .map(|_| ()) +} diff --git a/core/src/object/media/mod.rs b/core/src/object/media/mod.rs index 4eee9c3ca..952aa2092 100644 --- a/core/src/object/media/mod.rs +++ b/core/src/object/media/mod.rs @@ -1,18 +1,29 @@ -pub mod media_data_extractor; +use sd_core_prisma_helpers::object_with_media_data; +use sd_media_metadata::{ + ffmpeg::{ + audio_props::AudioProps, + chapter::Chapter, + codec::{Codec, Props}, + program::Program, + stream::Stream, + video_props::VideoProps, + }, + ExifMetadata, FFmpegMetadata, +}; +use sd_prisma::prisma::{ + exif_data::*, ffmpeg_media_audio_props, ffmpeg_media_chapter, ffmpeg_media_video_props, +}; + +pub mod exif_metadata_extractor; +pub mod ffmpeg_metadata_extractor; pub mod old_media_processor; pub mod old_thumbnail; pub use old_media_processor::OldMediaProcessorJobInit; -use sd_media_metadata::ImageMetadata; -use sd_prisma::prisma::media_data::*; +use sd_utils::db::ffmpeg_data_field_from_db; -use self::media_data_extractor::MediaDataError; - -pub fn media_data_image_to_query( - mdi: ImageMetadata, - object_id: object_id::Type, -) -> Result { - Ok(CreateUnchecked { +pub fn exif_data_image_to_query(mdi: ExifMetadata, object_id: object_id::Type) -> CreateUnchecked { + CreateUnchecked { object_id, _params: vec![ camera_data::set(serde_json::to_vec(&mdi.camera_data).ok()), @@ -25,11 +36,11 @@ pub fn media_data_image_to_query( exif_version::set(mdi.exif_version), epoch_time::set(mdi.date_taken.map(|x| x.unix_timestamp())), ], - }) + } } -pub fn media_data_image_to_query_params( - mdi: ImageMetadata, +pub fn exif_data_image_to_query_params( + mdi: ExifMetadata, ) -> (Vec<(&'static str, rmpv::Value)>, Vec) { use sd_sync::option_sync_db_entry; use sd_utils::chain_optional_iter; @@ -50,10 +61,8 @@ pub fn media_data_image_to_query_params( .unzip() } -pub fn media_data_image_from_prisma_data( - data: sd_prisma::prisma::media_data::Data, -) -> Result { - Ok(ImageMetadata { +pub fn exif_media_data_from_prisma_data(data: sd_prisma::prisma::exif_data::Data) -> ExifMetadata { + ExifMetadata { camera_data: from_slice_option_to_option(data.camera_data).unwrap_or_default(), date_taken: from_slice_option_to_option(data.media_date).unwrap_or_default(), resolution: from_slice_option_to_option(data.resolution).unwrap_or_default(), @@ -62,7 +71,201 @@ pub fn media_data_image_from_prisma_data( description: data.description, copyright: data.copyright, exif_version: data.exif_version, - }) + } +} + +pub fn ffmpeg_data_from_prisma_data( + object_with_media_data::ffmpeg_data::Data { + formats, + duration, + start_time, + bit_rate, + metadata, + chapters, + programs, + .. + }: object_with_media_data::ffmpeg_data::Data, +) -> FFmpegMetadata { + FFmpegMetadata { + formats: formats.split(',').map(String::from).collect::>(), + duration: duration.map(|duration| { + let duration = ffmpeg_data_field_from_db(&duration); + ((duration >> 32) as u32, duration as u32) + }), + start_time: start_time.map(|start_time| { + let start_time = ffmpeg_data_field_from_db(&start_time); + ((start_time >> 32) as u32, start_time as u32) + }), + bit_rate: { + let bit_rate = ffmpeg_data_field_from_db(&bit_rate); + ((bit_rate >> 32) as u32, bit_rate as u32) + }, + chapters: chapters + .into_iter() + .map( + |ffmpeg_media_chapter::Data { + chapter_id, + start, + end, + time_base_den, + time_base_num, + metadata, + .. + }| Chapter { + id: chapter_id, + start: { + let start = ffmpeg_data_field_from_db(&start); + ((start >> 32) as u32, start as u32) + }, + end: { + let end = ffmpeg_data_field_from_db(&end); + ((end >> 32) as u32, end as u32) + }, + time_base_den, + time_base_num, + metadata: from_slice_option_to_option(metadata).unwrap_or_default(), + }, + ) + .collect(), + programs: programs + .into_iter() + .map( + |object_with_media_data::ffmpeg_data::programs::Data { + program_id, + name, + metadata, + streams, + .. + }| Program { + id: program_id, + name, + streams: streams + .into_iter() + .map( + |object_with_media_data::ffmpeg_data::programs::streams::Data { + stream_id, + name, + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + dispositions, + metadata, + codec, + .. + }| { + Stream { + id: stream_id, + name, + codec: codec.map( + |object_with_media_data::ffmpeg_data::programs::streams::codec::Data{ + kind, + sub_kind, + tag, + name, + profile, + bit_rate, + audio_props, + video_props, + .. + }| Codec { + kind, + sub_kind, + tag, + name, + profile, + bit_rate, + props: match (audio_props, video_props) { + ( + Some(ffmpeg_media_audio_props::Data { + delay, + padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + .. + }), + None, + ) => Some(Props::Audio(AudioProps { + delay, + padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + })), + ( + None, + Some(ffmpeg_media_video_props::Data { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties, + .. + }), + ) => Some(Props::Video(VideoProps { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties: properties + .map(|dispositions| { + dispositions + .split(',') + .map(String::from) + .collect::>() + }) + .unwrap_or_default(), + })), + _ => None, + }, + } + ), + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + dispositions: dispositions + .map(|dispositions| { + dispositions + .split(',') + .map(String::from) + .collect::>() + }) + .unwrap_or_default(), + metadata: from_slice_option_to_option(metadata).unwrap_or_default(), + } + }, + ) + .collect(), + metadata: from_slice_option_to_option(metadata).unwrap_or_default(), + }, + ) + .collect(), + metadata: from_slice_option_to_option(metadata).unwrap_or_default(), + } } #[must_use] diff --git a/core/src/object/media/old_media_processor/job.rs b/core/src/object/media/old_media_processor/job.rs index 47aa5eff0..78e3960d0 100644 --- a/core/src/object/media/old_media_processor/job.rs +++ b/core/src/object/media/old_media_processor/job.rs @@ -2,6 +2,7 @@ use crate::{ invalidate_query, library::Library, location::ScanState, + object::media::ffmpeg_metadata_extractor, old_job::{ CurrentStep, JobError, JobInitOutput, JobReportUpdate, JobResult, JobStepOutput, StatefulJob, WorkerContext, @@ -45,9 +46,10 @@ use tokio::time::sleep; use tracing::{debug, error, info, trace, warn}; use super::{ - media_data_extractor, + exif_metadata_extractor, old_thumbnail::{self, GenerateThumbnailArgs}, - process, BatchToProcess, MediaProcessorError, OldMediaProcessorMetadata, + process_audio_and_video, process_images, BatchToProcess, MediaProcessorError, + OldMediaProcessorMetadata, }; const BATCH_SIZE: usize = 10; @@ -84,7 +86,8 @@ pub struct OldMediaProcessorJobData { #[derive(Debug, Serialize, Deserialize)] pub enum OldMediaProcessorJobStep { - ExtractMediaData(Vec), + ExtractImageMediaData(Vec), + ExtractAudioAndVideoMediaData(Vec), WaitThumbnails(usize), #[cfg(feature = "ai")] WaitLabels(usize), @@ -176,7 +179,10 @@ impl StatefulJob for OldMediaProcessorJobInit { None }; - let file_paths = get_files_for_media_data_extraction(db, &iso_file_path).await?; + let file_paths_to_extract_exif_data = + get_files_for_image_media_data_extraction(db, &iso_file_path).await?; + let file_paths_to_extract_ffmpeg_data = + get_files_for_audio_and_video_media_data_extraction(db, &iso_file_path).await?; #[cfg(feature = "ai")] let file_paths_for_labeling = @@ -202,14 +208,23 @@ impl StatefulJob for OldMediaProcessorJobInit { (uuid::Uuid::new_v4(), None) }; - let total_files = file_paths.len(); + let total_files = + file_paths_to_extract_exif_data.len() + file_paths_to_extract_ffmpeg_data.len(); - let chunked_files = file_paths + let chunked_files = file_paths_to_extract_exif_data .into_iter() .chunks(BATCH_SIZE) .into_iter() .map(|chunk| chunk.collect::>()) - .map(OldMediaProcessorJobStep::ExtractMediaData) + .map(OldMediaProcessorJobStep::ExtractImageMediaData) + .chain( + file_paths_to_extract_ffmpeg_data + .into_iter() + .chunks(BATCH_SIZE) + .into_iter() + .map(|chunk| chunk.collect::>()) + .map(OldMediaProcessorJobStep::ExtractAudioAndVideoMediaData), + ) .chain( [(thumbs_to_process_count > 0).then_some( OldMediaProcessorJobStep::WaitThumbnails(thumbs_to_process_count as usize), @@ -272,7 +287,7 @@ impl StatefulJob for OldMediaProcessorJobInit { _: &Self::RunMetadata, ) -> Result, JobError> { match step { - OldMediaProcessorJobStep::ExtractMediaData(file_paths) => process( + OldMediaProcessorJobStep::ExtractImageMediaData(file_paths) => process_images( file_paths, self.location.id, &data.location_path, @@ -287,6 +302,23 @@ impl StatefulJob for OldMediaProcessorJobInit { .map(Into::into) .map_err(Into::into), + OldMediaProcessorJobStep::ExtractAudioAndVideoMediaData(file_paths) => { + process_audio_and_video( + file_paths, + self.location.id, + &data.location_path, + &ctx.library.db, + &|completed_count| { + ctx.progress(vec![JobReportUpdate::CompletedTaskCount( + step_number * BATCH_SIZE + completed_count, + )]); + }, + ) + .await + .map(Into::into) + .map_err(Into::into) + } + OldMediaProcessorJobStep::WaitThumbnails(total_thumbs) => { ctx.progress(vec![ JobReportUpdate::TaskCount(*total_thumbs), @@ -417,7 +449,7 @@ impl StatefulJob for OldMediaProcessorJobInit { .display() ); - if run_metadata.media_data.extracted > 0 { + if run_metadata.exif_data.extracted > 0 || run_metadata.ffmpeg_data.extracted > 0 { invalidate_query!(ctx.library, "search.paths"); } @@ -512,14 +544,27 @@ async fn dispatch_thumbnails_for_processing( Ok(thumbs_count as u32) } -async fn get_files_for_media_data_extraction( +async fn get_files_for_image_media_data_extraction( db: &PrismaClient, parent_iso_file_path: &IsolatedFilePathData<'_>, ) -> Result, MediaProcessorError> { get_all_children_files_by_extensions( db, parent_iso_file_path, - &media_data_extractor::FILTERED_IMAGE_EXTENSIONS, + &exif_metadata_extractor::FILTERED_IMAGE_EXTENSIONS, + ) + .await + .map_err(Into::into) +} + +async fn get_files_for_audio_and_video_media_data_extraction( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result, MediaProcessorError> { + get_all_children_files_by_extensions( + db, + parent_iso_file_path, + &ffmpeg_metadata_extractor::FILTERED_AUDIO_AND_VIDEO_EXTENSIONS, ) .await .map_err(Into::into) @@ -546,7 +591,7 @@ async fn get_files_for_labeling( ORDER BY materialized_path ASC", // Ordering by materialized_path so we can prioritize processing the first files // in the above part of the directories tree - &media_data_extractor::FILTERED_IMAGE_EXTENSIONS + &exif_metadata_extractor::FILTERED_IMAGE_EXTENSIONS .iter() .map(|ext| format!("LOWER('{ext}')")) .collect::>() diff --git a/core/src/object/media/old_media_processor/mod.rs b/core/src/object/media/old_media_processor/mod.rs index 6fefbb3a5..b89010ec7 100644 --- a/core/src/object/media/old_media_processor/mod.rs +++ b/core/src/object/media/old_media_processor/mod.rs @@ -12,7 +12,8 @@ use thiserror::Error; use tracing::error; use super::{ - media_data_extractor::{self, MediaDataError, OldMediaDataExtractorMetadata}, + exif_metadata_extractor::{self, ExifDataError, OldExifDataExtractorMetadata}, + ffmpeg_metadata_extractor::{self, FFmpegDataError, OldFFmpegDataExtractorMetadata}, old_thumbnail::{self, BatchToProcess, ThumbnailerError}, }; @@ -35,20 +36,35 @@ pub enum MediaProcessorError { #[error(transparent)] Thumbnailer(#[from] ThumbnailerError), #[error(transparent)] - MediaDataExtractor(#[from] MediaDataError), + ExifMediaDataExtractor(#[from] ExifDataError), + #[error(transparent)] + FFmpegDataExtractor(#[from] FFmpegDataError), } #[derive(Debug, Serialize, Deserialize, Default)] pub struct OldMediaProcessorMetadata { - media_data: OldMediaDataExtractorMetadata, + exif_data: OldExifDataExtractorMetadata, + ffmpeg_data: OldFFmpegDataExtractorMetadata, thumbs_processed: u32, labels_extracted: u32, } -impl From for OldMediaProcessorMetadata { - fn from(media_data: OldMediaDataExtractorMetadata) -> Self { +impl From for OldMediaProcessorMetadata { + fn from(exif_data: OldExifDataExtractorMetadata) -> Self { Self { - media_data, + exif_data, + ffmpeg_data: Default::default(), + thumbs_processed: 0, + labels_extracted: 0, + } + } +} + +impl From for OldMediaProcessorMetadata { + fn from(ffmpeg_data: OldFFmpegDataExtractorMetadata) -> Self { + Self { + exif_data: Default::default(), + ffmpeg_data, thumbs_processed: 0, labels_extracted: 0, } @@ -57,24 +73,37 @@ impl From for OldMediaProcessorMetadata { impl JobRunMetadata for OldMediaProcessorMetadata { fn update(&mut self, new_data: Self) { - self.media_data.extracted += new_data.media_data.extracted; - self.media_data.skipped += new_data.media_data.skipped; + self.exif_data.extracted += new_data.exif_data.extracted; + self.exif_data.skipped += new_data.exif_data.skipped; + self.ffmpeg_data.extracted += new_data.ffmpeg_data.extracted; + self.ffmpeg_data.skipped += new_data.ffmpeg_data.skipped; self.thumbs_processed += new_data.thumbs_processed; self.labels_extracted += new_data.labels_extracted; } } -pub async fn process( +pub async fn process_images( files_paths: &[file_path_for_media_processor::Data], location_id: location::id::Type, - location_path: impl AsRef, + location_path: impl AsRef + Send, db: &PrismaClient, ctx_update_fn: &impl Fn(usize), ) -> Result<(OldMediaProcessorMetadata, JobRunErrors), MediaProcessorError> { - // Add here new kinds of media processing if necessary in the future - - media_data_extractor::process(files_paths, location_id, location_path, db, ctx_update_fn) + exif_metadata_extractor::process(files_paths, location_id, location_path, db, ctx_update_fn) .await - .map(|(media_data, errors)| (media_data.into(), errors)) + .map(|(exif_extraction_metadata, errors)| (exif_extraction_metadata.into(), errors)) + .map_err(Into::into) +} + +pub async fn process_audio_and_video( + files_paths: &[file_path_for_media_processor::Data], + location_id: location::id::Type, + location_path: impl AsRef + Send, + db: &PrismaClient, + ctx_update_fn: &impl Fn(usize), +) -> Result<(OldMediaProcessorMetadata, JobRunErrors), MediaProcessorError> { + ffmpeg_metadata_extractor::process(files_paths, location_id, location_path, db, ctx_update_fn) + .await + .map(|(ffmpeg_extraction_metadata, errors)| (ffmpeg_extraction_metadata.into(), errors)) .map_err(Into::into) } diff --git a/core/src/object/media/old_media_processor/shallow.rs b/core/src/object/media/old_media_processor/shallow.rs index 59d89ca2f..b45d3fa48 100644 --- a/core/src/object/media/old_media_processor/shallow.rs +++ b/core/src/object/media/old_media_processor/shallow.rs @@ -1,7 +1,6 @@ use crate::{ invalidate_query, library::Library, - object::media::old_thumbnail::GenerateThumbnailArgs, old_job::{JobError, JobRunMetadata}, Node, }; @@ -32,8 +31,8 @@ use tracing::{debug, error}; use futures::StreamExt; use super::{ - media_data_extractor::{self, process}, - old_thumbnail::{self, BatchToProcess}, + exif_metadata_extractor, ffmpeg_metadata_extractor, + old_thumbnail::{self, BatchToProcess, GenerateThumbnailArgs}, MediaProcessorError, OldMediaProcessorMetadata, }; @@ -92,7 +91,10 @@ pub async fn old_shallow( ) .await?; - let file_paths = get_files_for_media_data_extraction(db, &iso_file_path).await?; + let file_paths_to_extract_exif_data = + get_files_for_exif_media_data_extraction(db, &iso_file_path).await?; + let file_paths_to_extract_ffmpeg_data = + get_files_for_ffmpeg_media_data_extraction(db, &iso_file_path).await?; #[cfg(feature = "ai")] let file_paths_for_labelling = @@ -101,9 +103,17 @@ pub async fn old_shallow( #[cfg(feature = "ai")] let has_labels = !file_paths_for_labelling.is_empty(); - let total_files = file_paths.len(); + let total_files = + file_paths_to_extract_exif_data.len() + file_paths_to_extract_ffmpeg_data.len(); - let chunked_files = file_paths + let chunked_files_to_extract_exif_data = file_paths_to_extract_exif_data + .into_iter() + .chunks(BATCH_SIZE) + .into_iter() + .map(Iterator::collect) + .collect::>>(); + + let chunked_files_to_extract_ffmpeg_data = file_paths_to_extract_ffmpeg_data .into_iter() .chunks(BATCH_SIZE) .into_iter() @@ -112,7 +122,7 @@ pub async fn old_shallow( debug!( "Preparing to process {total_files} files in {} chunks", - chunked_files.len() + chunked_files_to_extract_exif_data.len() + chunked_files_to_extract_ffmpeg_data.len() ); #[cfg(feature = "ai")] @@ -131,21 +141,35 @@ pub async fn old_shallow( let mut run_metadata = OldMediaProcessorMetadata::default(); - for files in chunked_files { - let (more_run_metadata, errors) = process(&files, location.id, &location_path, db, &|_| {}) - .await - .map_err(MediaProcessorError::from)?; + for files in chunked_files_to_extract_exif_data { + let (more_run_metadata, errors) = + exif_metadata_extractor::process(&files, location.id, &location_path, db, &|_| {}) + .await + .map_err(MediaProcessorError::from)?; run_metadata.update(more_run_metadata.into()); if !errors.is_empty() { - error!("Errors processing chunk of media data shallow extraction:\n{errors}"); + error!("Errors processing chunk of image media data shallow extraction:\n{errors}"); + } + } + + for files in chunked_files_to_extract_ffmpeg_data { + let (more_run_metadata, errors) = + ffmpeg_metadata_extractor::process(&files, location.id, &location_path, db, &|_| {}) + .await + .map_err(MediaProcessorError::from)?; + + run_metadata.update(more_run_metadata.into()); + + if !errors.is_empty() { + error!("Errors processing chunk of audio or video media data shallow extraction:\n{errors}"); } } debug!("Media shallow processor run metadata: {run_metadata:?}"); - if run_metadata.media_data.extracted > 0 { + if run_metadata.exif_data.extracted > 0 || run_metadata.ffmpeg_data.extracted > 0 { invalidate_query!(library, "search.paths"); invalidate_query!(library, "search.objects"); } @@ -183,14 +207,27 @@ pub async fn old_shallow( Ok(()) } -async fn get_files_for_media_data_extraction( +async fn get_files_for_exif_media_data_extraction( db: &PrismaClient, parent_iso_file_path: &IsolatedFilePathData<'_>, ) -> Result, MediaProcessorError> { get_files_by_extensions( db, parent_iso_file_path, - &media_data_extractor::FILTERED_IMAGE_EXTENSIONS, + &exif_metadata_extractor::FILTERED_IMAGE_EXTENSIONS, + ) + .await + .map_err(Into::into) +} + +async fn get_files_for_ffmpeg_media_data_extraction( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result, MediaProcessorError> { + get_files_by_extensions( + db, + parent_iso_file_path, + &ffmpeg_metadata_extractor::FILTERED_AUDIO_AND_VIDEO_EXTENSIONS, ) .await .map_err(Into::into) @@ -214,7 +251,7 @@ async fn get_files_for_labeling( AND LOWER(extension) IN ({}) AND materialized_path = {{}} {}", - &media_data_extractor::FILTERED_IMAGE_EXTENSIONS + &exif_metadata_extractor::FILTERED_IMAGE_EXTENSIONS .iter() .map(|ext| format!("LOWER('{ext}')")) .collect::>() diff --git a/core/src/object/media/old_thumbnail/process.rs b/core/src/object/media/old_thumbnail/process.rs index 000f368ef..f9983f9e5 100644 --- a/core/src/object/media/old_thumbnail/process.rs +++ b/core/src/object/media/old_thumbnail/process.rs @@ -2,7 +2,7 @@ use crate::api::CoreEvent; use sd_file_ext::extensions::{DocumentExtension, ImageExtension}; use sd_images::{format_image, scale_dimensions, ConvertibleExtension}; -use sd_media_metadata::image::Orientation; +use sd_media_metadata::exif::Orientation; use sd_prisma::prisma::location; use sd_utils::error::FileIOError; @@ -467,12 +467,17 @@ async fn generate_image_thumbnail( #[cfg(feature = "ffmpeg")] async fn generate_video_thumbnail( - file_path: impl AsRef, - output_path: impl AsRef, + file_path: impl AsRef + Send, + output_path: impl AsRef + Send, ) -> Result<(), ThumbnailerError> { - use sd_ffmpeg::to_thumbnail; + use sd_ffmpeg::{to_thumbnail, ThumbnailSize}; - to_thumbnail(file_path, output_path, 256, TARGET_QUALITY) - .await - .map_err(Into::into) + to_thumbnail( + file_path, + output_path, + ThumbnailSize::Scale(256), + TARGET_QUALITY, + ) + .await + .map_err(Into::into) } diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index f31fd9b8e..01c9aa4ca 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" authors = ["Ericson Soares "] readme = "README.md" description = "A simple library to generate video thumbnails using ffmpeg with the webp format" -rust-version = "1.75.0" +rust-version = "1.75" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } diff --git a/crates/crypto/Cargo.toml b/crates/crypto/Cargo.toml index 5efb8be08..4b570d2e2 100644 --- a/crates/crypto/Cargo.toml +++ b/crates/crypto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sd-crypto" -rust-version = "1.72.0" +rust-version = "1.72" version = "0.0.0" authors = ["Jake Robinson "] description = """ diff --git a/crates/ffmpeg/Cargo.toml b/crates/ffmpeg/Cargo.toml index 04c346be1..e6c8d7150 100644 --- a/crates/ffmpeg/Cargo.toml +++ b/crates/ffmpeg/Cargo.toml @@ -1,15 +1,23 @@ [package] name = "sd-ffmpeg" version = "0.1.0" -authors = ["Ericson Soares "] +authors = [ + "Ericson Soares ", + "Vítor Vasconcellos ", +] readme = "README.md" description = "A simple library to generate video thumbnails using ffmpeg with the webp format" -rust-version = "1.64.0" +rust-version = "1.78" license = { workspace = true } repository = { workspace = true } edition = { workspace = true } [dependencies] +sd-utils = { path = "../utils" } + +chrono = { workspace = true, features = ["serde"] } +image = { workspace = true } +libc = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "rt"] } tracing = { workspace = true } @@ -17,7 +25,6 @@ webp = { workspace = true } ffmpeg-sys-next = "6.0.1" - [dev-dependencies] tempfile = { workspace = true } tokio = { workspace = true, features = ["fs", "rt", "macros"] } diff --git a/crates/ffmpeg/README.md b/crates/ffmpeg/README.md index 385f75a60..09419078a 100644 --- a/crates/ffmpeg/README.md +++ b/crates/ffmpeg/README.md @@ -29,7 +29,6 @@ async fn main() -> Result<(), ThumbnailerError> { let thumbnailer = ThumbnailerBuilder::new() .width_and_height(420, 315) .seek_percentage(0.25)? - .with_film_strip(false) .quality(80.0)? .build(); diff --git a/crates/ffmpeg/src/codec_ctx.rs b/crates/ffmpeg/src/codec_ctx.rs new file mode 100644 index 000000000..1b5690d62 --- /dev/null +++ b/crates/ffmpeg/src/codec_ctx.rs @@ -0,0 +1,460 @@ +use crate::{ + error::{Error, FFmpegError}, + model::{FFmpegAudioProps, FFmpegCodec, FFmpegProps, FFmpegSubtitleProps, FFmpegVideoProps}, + utils::check_error, +}; + +use std::{ + ffi::{CStr, CString}, + ptr, +}; + +use ffmpeg_sys_next::{ + av_bprint_finalize, av_bprint_init, av_channel_layout_describe_bprint, av_chroma_location_name, + av_color_primaries_name, av_color_range_name, av_color_space_name, av_color_transfer_name, + av_fourcc_make_string, av_get_bits_per_sample, av_get_bytes_per_sample, + av_get_media_type_string, av_get_pix_fmt_name, av_get_sample_fmt_name, av_pix_fmt_desc_get, + av_reduce, avcodec_alloc_context3, avcodec_flush_buffers, avcodec_free_context, + avcodec_get_name, avcodec_open2, avcodec_parameters_to_context, avcodec_profile_name, + avcodec_receive_frame, avcodec_send_packet, AVBPrint, AVChromaLocation, AVCodec, + AVCodecContext, AVCodecParameters, AVColorPrimaries, AVColorRange, AVColorSpace, + AVColorTransferCharacteristic, AVFieldOrder, AVFrame, AVMediaType, AVPacket, AVPixelFormat, + AVRational, AVSampleFormat, AVERROR, AVERROR_EOF, AV_FOURCC_MAX_STRING_SIZE, + FF_CODEC_PROPERTY_CLOSED_CAPTIONS, FF_CODEC_PROPERTY_FILM_GRAIN, FF_CODEC_PROPERTY_LOSSLESS, +}; +use libc::EAGAIN; + +pub struct FFmpegCodecContext(*mut AVCodecContext); + +impl FFmpegCodecContext { + pub(crate) fn new() -> Result { + let ptr = unsafe { avcodec_alloc_context3(ptr::null_mut()) }; + if ptr.is_null() { + Err(FFmpegError::VideoCodecAllocation)?; + } + + Ok(Self(ptr)) + } + + pub(crate) fn as_ref(&self) -> &AVCodecContext { + unsafe { self.0.as_ref() }.expect("initialized on struct creation") + } + + pub(crate) fn as_mut(&mut self) -> &mut AVCodecContext { + unsafe { self.0.as_mut() }.expect("initialized on struct creation") + } + + pub(crate) fn parameters_to_context( + &mut self, + codec_params: &AVCodecParameters, + ) -> Result<&Self, Error> { + check_error( + unsafe { avcodec_parameters_to_context(self.as_mut(), codec_params) }, + "Fail to fill the codec context with codec parameters", + )?; + + Ok(self) + } + + pub(crate) fn open2(&mut self, video_codec: &AVCodec) -> Result<&Self, Error> { + check_error( + unsafe { avcodec_open2(self.as_mut(), video_codec, ptr::null_mut()) }, + "Failed to open video codec", + )?; + + Ok(self) + } + + pub(crate) fn flush(&mut self) { + unsafe { avcodec_flush_buffers(self.as_mut()) }; + } + + pub(crate) fn send_packet(&mut self, packet: *mut AVPacket) -> Result { + match unsafe { avcodec_send_packet(self.as_mut(), packet) } { + AVERROR_EOF => Ok(false), + ret if ret == AVERROR(EAGAIN) => Err(FFmpegError::Again), + ret if ret < 0 => Err(FFmpegError::from(ret)), + _ => Ok(true), + } + } + + pub(crate) fn receive_frame(&mut self, frame: *mut AVFrame) -> Result { + match unsafe { avcodec_receive_frame(self.as_mut(), frame) } { + AVERROR_EOF => Ok(false), + ret if ret == AVERROR(EAGAIN) => Err(FFmpegError::Again), + ret if ret < 0 => Err(FFmpegError::from(ret)), + _ => Ok(true), + } + } + + fn kind(&self) -> (Option, Option) { + let kind = unsafe { av_get_media_type_string(self.as_ref().codec_type).as_ref() } + .map(|media_type| unsafe { CStr::from_ptr(media_type) }); + + let sub_kind = unsafe { self.as_ref().codec.as_ref() } + .and_then(|codec| unsafe { codec.name.as_ref() }) + .map(|name| unsafe { CStr::from_ptr(name) }) + .and_then(|sub_kind| { + if let Some(kind) = kind { + if kind == sub_kind { + return None; + } + } + + Some(String::from_utf8_lossy(sub_kind.to_bytes()).to_string()) + }); + + ( + kind.map(|cstr| String::from_utf8_lossy(cstr.to_bytes()).to_string()), + sub_kind, + ) + } + + fn name(&self) -> Option { + unsafe { avcodec_get_name(self.as_ref().codec_id).as_ref() }.map(|codec_name| { + let cstr = unsafe { CStr::from_ptr(codec_name) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + } + + fn profile(&self) -> Option { + if self.as_ref().profile == 0 { + None + } else { + unsafe { avcodec_profile_name(self.as_ref().codec_id, self.as_ref().profile).as_ref() } + .map(|profile| { + let cstr = unsafe { CStr::from_ptr(profile) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + } + } + + fn tag(&self) -> Option { + if self.as_ref().codec_tag != 0 { + CString::new(vec![ + 0; + usize::try_from(AV_FOURCC_MAX_STRING_SIZE).expect( + "AV_FOURCC_MAX_STRING_SIZE is 32, must fit in an usize" + ) + ]) + .ok() + .map(|buffer| { + let tag = unsafe { + CString::from_raw(av_fourcc_make_string( + buffer.into_raw(), + self.as_ref().codec_tag, + )) + }; + String::from_utf8_lossy(tag.as_bytes()).to_string() + }) + } else { + None + } + } + + fn bit_rate(&self) -> i32 { + // TODO: use i64 instead of i32 when rspc supports it + let ctx = self.as_ref(); + match self.as_ref().codec_type { + AVMediaType::AVMEDIA_TYPE_VIDEO + | AVMediaType::AVMEDIA_TYPE_DATA + | AVMediaType::AVMEDIA_TYPE_SUBTITLE + | AVMediaType::AVMEDIA_TYPE_ATTACHMENT => ctx.bit_rate.try_into().unwrap_or_default(), + AVMediaType::AVMEDIA_TYPE_AUDIO => { + let bits_per_sample = unsafe { av_get_bits_per_sample(ctx.codec_id) }; + if bits_per_sample != 0 { + let bit_rate = ctx.sample_rate * ctx.ch_layout.nb_channels; + if bit_rate <= i32::MAX / bits_per_sample { + return bit_rate * (bits_per_sample); + } + } + ctx.bit_rate.try_into().unwrap_or_default() + } + _ => 0, + } + } + + fn video_props(&self) -> Option { + let ctx = self.as_ref(); + if ctx.codec_type != AVMediaType::AVMEDIA_TYPE_VIDEO { + return None; + } + + let pixel_format = extract_pixel_format(ctx); + + let bits_per_channel = extract_bits_per_channel(ctx); + + let color_range = extract_color_range(ctx); + + let (color_space, color_primaries, color_transfer) = extract_colors(ctx); + + // Field Order + let field_order = extract_field_order(ctx); + + // Chroma Sample Location + let chroma_location = extract_chroma_location(ctx); + + let width = ctx.width; + let height = ctx.height; + + let (aspect_ratio_num, aspect_ratio_den) = extract_aspect_ratio(ctx, width, height); + + let mut properties = vec![]; + if ctx.properties & (FF_CODEC_PROPERTY_LOSSLESS.unsigned_abs()) != 0 { + properties.push("Closed Captions".to_string()); + } + if ctx.properties & (FF_CODEC_PROPERTY_CLOSED_CAPTIONS.unsigned_abs()) != 0 { + properties.push("Film Grain".to_string()); + } + if ctx.properties & (FF_CODEC_PROPERTY_FILM_GRAIN.unsigned_abs()) != 0 { + properties.push("lossless".to_string()); + } + + Some(FFmpegVideoProps { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties, + }) + } + + fn audio_props(&self) -> Option { + let ctx = self.as_ref(); + if ctx.codec_type != AVMediaType::AVMEDIA_TYPE_AUDIO { + return None; + } + + let sample_rate = if ctx.sample_rate > 0 { + Some(ctx.sample_rate) + } else { + None + }; + + let mut bprint = AVBPrint { + str_: ptr::null_mut(), + len: 0, + size: 0, + size_max: 0, + reserved_internal_buffer: [0; 1], + reserved_padding: [0; 1000], + }; + unsafe { + av_bprint_init(&mut bprint, 0, u32::MAX /* AV_BPRINT_SIZE_UNLIMITED */); + }; + let mut channel_layout = ptr::null_mut(); + let channel_layout = + if unsafe { av_channel_layout_describe_bprint(&ctx.ch_layout, &mut bprint) } < 0 + || unsafe { av_bprint_finalize(&mut bprint, &mut channel_layout) } < 0 + || channel_layout.is_null() + { + None + } else { + let cstr = unsafe { CStr::from_ptr(channel_layout) }; + Some(String::from_utf8_lossy(cstr.to_bytes()).to_string()) + }; + + let sample_format = if ctx.sample_fmt == AVSampleFormat::AV_SAMPLE_FMT_NONE { + None + } else { + unsafe { av_get_sample_fmt_name(ctx.sample_fmt).as_ref() }.map(|sample_fmt| { + let cstr = unsafe { CStr::from_ptr(sample_fmt) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + }; + + let bit_per_sample = if ctx.bits_per_raw_sample > 0 + && ctx.bits_per_raw_sample != unsafe { av_get_bytes_per_sample(ctx.sample_fmt) } * 8 + { + Some(ctx.bits_per_raw_sample) + } else { + None + }; + + Some(FFmpegAudioProps { + delay: ctx.initial_padding, + padding: ctx.trailing_padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + }) + } + + fn subtitle_props(&self) -> Option { + if self.as_ref().codec_type != AVMediaType::AVMEDIA_TYPE_SUBTITLE { + return None; + } + + Some(FFmpegSubtitleProps { + width: self.as_ref().width, + height: self.as_ref().height, + }) + } + + fn props(&self) -> Option { + match self.as_ref().codec_type { + AVMediaType::AVMEDIA_TYPE_VIDEO => self.video_props().map(FFmpegProps::Video), + AVMediaType::AVMEDIA_TYPE_AUDIO => self.audio_props().map(FFmpegProps::Audio), + AVMediaType::AVMEDIA_TYPE_SUBTITLE => self.subtitle_props().map(FFmpegProps::Subtitle), + _ => None, + } + } +} + +fn extract_aspect_ratio( + ctx: &AVCodecContext, + width: i32, + height: i32, +) -> (Option, Option) { + if ctx.sample_aspect_ratio.num == 0 { + (None, None) + } else { + let mut display_aspect_ratio = AVRational { num: 0, den: 0 }; + let num = i64::from(width * ctx.sample_aspect_ratio.num); + let den = i64::from(height * ctx.sample_aspect_ratio.den); + let max = 1024 * 1024; + unsafe { + av_reduce( + &mut display_aspect_ratio.num, + &mut display_aspect_ratio.den, + num, + den, + max, + ); + } + + ( + Some(display_aspect_ratio.num), + Some(display_aspect_ratio.den), + ) + } +} + +fn extract_chroma_location(ctx: &AVCodecContext) -> Option { + if ctx.chroma_sample_location == AVChromaLocation::AVCHROMA_LOC_UNSPECIFIED { + None + } else { + unsafe { av_chroma_location_name(ctx.chroma_sample_location).as_ref() }.map( + |chroma_location| { + let cstr = unsafe { CStr::from_ptr(chroma_location) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }, + ) + } +} + +fn extract_field_order(ctx: &AVCodecContext) -> Option { + if ctx.field_order == AVFieldOrder::AV_FIELD_UNKNOWN { + None + } else { + Some( + (match ctx.field_order { + AVFieldOrder::AV_FIELD_TT => "top first", + AVFieldOrder::AV_FIELD_BB => "bottom first", + AVFieldOrder::AV_FIELD_TB => "top coded first (swapped)", + AVFieldOrder::AV_FIELD_BT => "bottom coded first (swapped)", + _ => "progressive", + }) + .to_string(), + ) + } +} + +fn extract_colors(ctx: &AVCodecContext) -> (Option, Option, Option) { + if ctx.colorspace == AVColorSpace::AVCOL_SPC_UNSPECIFIED + && ctx.color_primaries == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED + && ctx.color_trc == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED + { + (None, None, None) + } else { + let color_space = + unsafe { av_color_space_name(ctx.colorspace).as_ref() }.map(|color_space| { + let cstr = unsafe { CStr::from_ptr(color_space) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }); + let color_primaries = unsafe { av_color_primaries_name(ctx.color_primaries).as_ref() }.map( + |color_primaries| { + let cstr = unsafe { CStr::from_ptr(color_primaries) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }, + ); + let color_transfer = + unsafe { av_color_transfer_name(ctx.color_trc).as_ref() }.map(|color_transfer| { + let cstr = unsafe { CStr::from_ptr(color_transfer) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }); + + (color_space, color_primaries, color_transfer) + } +} + +fn extract_color_range(ctx: &AVCodecContext) -> Option { + if ctx.color_range == AVColorRange::AVCOL_RANGE_UNSPECIFIED { + None + } else { + unsafe { av_color_range_name(ctx.color_range).as_ref() }.map(|color_range| { + let cstr = unsafe { CStr::from_ptr(color_range) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + } +} + +fn extract_bits_per_channel(ctx: &AVCodecContext) -> Option { + if ctx.bits_per_raw_sample == 0 || ctx.pix_fmt == AVPixelFormat::AV_PIX_FMT_NONE { + None + } else { + unsafe { av_pix_fmt_desc_get(ctx.pix_fmt).as_ref() }.and_then(|pix_fmt_desc| { + let comp = pix_fmt_desc.comp[0]; + if ctx.bits_per_raw_sample < comp.depth { + Some(ctx.bits_per_raw_sample) + } else { + None + } + }) + } +} + +fn extract_pixel_format(ctx: &AVCodecContext) -> Option { + if ctx.pix_fmt == AVPixelFormat::AV_PIX_FMT_NONE { + None + } else { + unsafe { av_get_pix_fmt_name(ctx.pix_fmt).as_ref() }.map(|pixel_format| { + let cstr = unsafe { CStr::from_ptr(pixel_format) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + } +} + +impl Drop for FFmpegCodecContext { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { avcodec_free_context(&mut self.0) }; + self.0 = ptr::null_mut(); + } + } +} + +impl From<&FFmpegCodecContext> for FFmpegCodec { + fn from(ctx: &FFmpegCodecContext) -> Self { + let (kind, sub_kind) = ctx.kind(); + + Self { + kind, + sub_kind, + name: ctx.name(), + profile: ctx.profile(), + tag: ctx.tag(), + bit_rate: ctx.bit_rate(), + props: ctx.props(), + } + } +} diff --git a/crates/ffmpeg/src/dict.rs b/crates/ffmpeg/src/dict.rs new file mode 100644 index 000000000..7d1d5726b --- /dev/null +++ b/crates/ffmpeg/src/dict.rs @@ -0,0 +1,181 @@ +use crate::{error::Error, model::FFmpegMetadata, utils::check_error}; + +use std::{ffi::CStr, ptr}; + +use chrono::DateTime; +use ffmpeg_sys_next::{ + av_dict_free, av_dict_get, av_dict_iterate, av_dict_set, AVDictionary, AVDictionaryEntry, + AV_DICT_MATCH_CASE, +}; + +#[derive(Debug)] +pub struct FFmpegDictionary { + dict: *mut AVDictionary, + managed: bool, +} + +impl FFmpegDictionary { + pub(crate) fn new(av_dict: Option<&mut AVDictionary>) -> Self { + av_dict.map_or_else( + || Self { + dict: ptr::null_mut(), + managed: true, + }, + |ptr| Self { + dict: ptr, + managed: false, + }, + ) + } + + pub(crate) fn get(&self, key: &CStr) -> Option { + if self.dict.is_null() { + return None; + } + + unsafe { av_dict_get(self.dict, key.as_ptr(), ptr::null(), AV_DICT_MATCH_CASE).as_ref() } + .and_then(|entry| unsafe { entry.value.as_ref() }) + .map(|value| { + let cstr = unsafe { CStr::from_ptr(value) }; + String::from_utf8_lossy(cstr.to_bytes()).to_string() + }) + } + + pub(crate) fn remove(&mut self, key: &CStr) -> Result<(), Error> { + check_error( + unsafe { + av_dict_set( + &mut self.dict, + key.as_ptr(), + ptr::null(), + AV_DICT_MATCH_CASE, + ) + }, + "Fail to set dictionary key-value pair", + )?; + + Ok(()) + } +} + +impl Drop for FFmpegDictionary { + fn drop(&mut self) { + if self.managed && !self.dict.is_null() { + unsafe { av_dict_free(&mut self.dict) }; + self.dict = ptr::null_mut(); + } + } +} + +impl<'a> IntoIterator for &'a FFmpegDictionary { + type Item = (String, Option); + type IntoIter = FFmpegDictIter<'a>; + + #[inline] + fn into_iter(self) -> FFmpegDictIter<'a> { + FFmpegDictIter { + dict: self.dict, + prev: ptr::null(), + _lifetime: std::marker::PhantomData, + } + } +} + +pub struct FFmpegDictIter<'a> { + dict: *mut AVDictionary, + prev: *const AVDictionaryEntry, + _lifetime: std::marker::PhantomData<&'a ()>, +} + +impl<'a> Iterator for FFmpegDictIter<'a> { + type Item = (String, Option); + + fn next(&mut self) -> Option<(String, Option)> { + unsafe { av_dict_iterate(self.dict, self.prev).as_ref() }.and_then(|prev| { + self.prev = prev; + let key = unsafe { prev.key.as_ref() }.map(|key| unsafe { CStr::from_ptr(key) }); + let value = + unsafe { prev.value.as_ref() }.map(|value| unsafe { CStr::from_ptr(value) }); + + match (key, value) { + (None, _) => None, + (Some(key), None) => { + Some((String::from_utf8_lossy(key.to_bytes()).to_string(), None)) + } + (Some(key), Some(value)) => Some(( + String::from_utf8_lossy(key.to_bytes()).to_string(), + Some(String::from_utf8_lossy(value.to_bytes()).to_string()), + )), + } + }) + } +} + +impl From<&FFmpegDictionary> for FFmpegMetadata { + fn from(dict: &FFmpegDictionary) -> Self { + let mut media_metadata = Self::default(); + + for (key, value) in dict { + if let Some(value) = value { + match key.as_str() { + "album" => media_metadata.album = Some(value.clone()), + "album_artist" => media_metadata.album_artist = Some(value.clone()), + "artist" => media_metadata.artist = Some(value.clone()), + "comment" => media_metadata.comment = Some(value.clone()), + "composer" => media_metadata.composer = Some(value.clone()), + "copyright" => media_metadata.copyright = Some(value.clone()), + "creation_time" => { + if let Ok(creation_time) = DateTime::parse_from_rfc2822(&value) { + media_metadata.creation_time = Some(creation_time.into()); + } else if let Ok(creation_time) = DateTime::parse_from_rfc3339(&value) { + media_metadata.creation_time = Some(creation_time.into()); + } + } + "date" => { + if let Ok(date) = DateTime::parse_from_rfc2822(&value) { + media_metadata.date = Some(date.into()); + } else if let Ok(date) = DateTime::parse_from_rfc3339(&value) { + media_metadata.date = Some(date.into()); + } + } + "disc" => { + if let Ok(disc) = value.parse() { + media_metadata.disc = Some(disc); + } + } + "encoder" => media_metadata.encoder = Some(value.clone()), + "encoded_by" => media_metadata.encoded_by = Some(value.clone()), + "filename" => media_metadata.filename = Some(value.clone()), + "genre" => media_metadata.genre = Some(value.clone()), + "language" => media_metadata.language = Some(value.clone()), + "performer" => media_metadata.performer = Some(value.clone()), + "publisher" => media_metadata.publisher = Some(value.clone()), + "service_name" => media_metadata.service_name = Some(value.clone()), + "service_provider" => media_metadata.service_provider = Some(value.clone()), + "title" => media_metadata.title = Some(value.clone()), + "track" => { + if let Ok(track) = value.parse() { + media_metadata.track = Some(track); + } + } + "variant_bitrate" => { + if let Ok(variant_bit_rate) = value.parse() { + media_metadata.variant_bit_rate = Some(variant_bit_rate); + } + } + _ => { + media_metadata.custom.insert(key.clone(), value.clone()); + } + } + } + } + + media_metadata + } +} + +impl From for FFmpegMetadata { + fn from(dict: FFmpegDictionary) -> Self { + (&dict).into() + } +} diff --git a/crates/ffmpeg/src/error.rs b/crates/ffmpeg/src/error.rs index f399d3e92..57ef20e26 100644 --- a/crates/ffmpeg/src/error.rs +++ b/crates/ffmpeg/src/error.rs @@ -1,5 +1,9 @@ -use std::path::PathBuf; -use std::{ffi::c_int, num::TryFromIntError}; +use sd_utils::error::FileIOError; +use std::{ + ffi::{c_int, NulError}, + num::TryFromIntError, + path::{Path, PathBuf}, +}; use thiserror::Error; use tokio::task::JoinError; @@ -16,43 +20,48 @@ use ffmpeg_sys_next::{ /// Error type for the library. #[derive(Error, Debug)] pub enum Error { - #[error("I/O Error: {0}")] - Io(#[from] std::io::Error), + #[error("Background task failed: {0}")] + BackgroundTaskFailed(#[from] JoinError), + #[error("the video is most likely corrupt and will be skipped: ", .0.display())] + CorruptVideo(Box), + #[error("Received an invalid quality, expected range [0.0, 100.0], received: {0}")] + InvalidQuality(f32), + #[error("Received an invalid seek percentage: {0}")] + InvalidSeekPercentage(f32), + #[error("Error while casting an integer to another integer type")] + IntCastError(#[from] TryFromIntError), + #[error("Duration for video stream is unavailable")] + NoVideoDuration, + #[error("Failed to allocate C data: {0}")] + NulError(#[from] NulError), #[error("Path conversion error: Path: {0:#?}")] PathConversion(PathBuf), #[error("FFmpeg internal error: {0}")] - Ffmpeg(#[from] FfmpegError), + FFmpeg(#[from] FFmpegError), #[error("FFmpeg internal error: {0}; Reason: {1}")] - FfmpegWithReason(FfmpegError, String), + FFmpegWithReason(FFmpegError, String), #[error("Failed to decode video frame")] FrameDecodeError, #[error("Failed to seek video")] SeekError, #[error("Seek not allowed")] SeekNotAllowed, - #[error("Received an invalid seek percentage: {0}")] - InvalidSeekPercentage(f32), - #[error("Received an invalid quality, expected range [0.0, 100.0], received: {0}")] - InvalidQuality(f32), - #[error("Background task failed: {0}")] - BackgroundTaskFailed(#[from] JoinError), - #[error("The video is most likely corrupt and will be skipped")] - CorruptVideo, - #[error("Error while casting an integer to another integer type")] - IntCastError(#[from] TryFromIntError), + + #[error(transparent)] + FileIO(#[from] FileIOError), } /// Enum to represent possible errors from `FFmpeg` library /// /// Extracted from #[derive(Error, Debug)] -pub enum FfmpegError { +pub enum FFmpegError { #[error("Bitstream filter not found")] BitstreamFilterNotFound, - #[error("Internal bug, also see AVERROR_BUG2")] - InternalBug, #[error("Buffer too small")] BufferTooSmall, + #[error("Context allocation error")] + ContextAllocation, #[error("Decoder not found")] DecoderNotFound, #[error("Demuxer not found")] @@ -69,6 +78,8 @@ pub enum FfmpegError { FilterNotFound, #[error("Invalid data found when processing input")] InvalidData, + #[error("Internal bug, also see AVERROR_BUG2")] + InternalBug, #[error("Muxer not found")] MuxerNotFound, #[error("Option not found")] @@ -111,9 +122,13 @@ pub enum FfmpegError { FilterGraphAllocation, #[error("Codec Open Error")] CodecOpen, + #[error("Data not found")] + NullError, + #[error("Resource temporarily unavailable")] + Again, } -impl From for FfmpegError { +impl From for FFmpegError { fn from(code: c_int) -> Self { match code { AVERROR_BSF_NOT_FOUND => Self::BitstreamFilterNotFound, diff --git a/crates/ffmpeg/src/film_strip.rs b/crates/ffmpeg/src/film_strip.rs deleted file mode 100644 index b186e5095..000000000 --- a/crates/ffmpeg/src/film_strip.rs +++ /dev/null @@ -1,693 +0,0 @@ -use crate::video_frame::VideoFrame; - -static FILM_STRIP_4: [u8; 4 * 4 * 3] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 107, 107, 107, 135, 135, 135, 55, 55, 55, 0, 0, 0, - 159, 159, 159, 195, 195, 195, 82, 82, 82, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -]; - -static FILM_STRIP_8: [u8; 8 * 8 * 3] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 32, 32, 55, 55, 55, 58, - 58, 58, 58, 58, 58, 52, 52, 52, 1, 1, 1, 0, 0, 0, 2, 2, 2, 133, 133, 133, 208, 208, 208, 219, - 219, 219, 219, 219, 219, 203, 203, 203, 26, 26, 26, 0, 0, 0, 2, 2, 2, 158, 158, 158, 240, 240, - 240, 251, 251, 251, 251, 251, 251, 235, 235, 235, 31, 31, 31, 0, 0, 0, 0, 0, 0, 70, 70, 70, - 115, 115, 115, 121, 121, 121, 121, 121, 121, 110, 110, 110, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, -]; - -static FILM_STRIP_16: [u8; 16 * 16 * 3] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 9, 9, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, - 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 56, 56, 56, 89, 89, 89, 114, 114, 114, 124, 124, 124, 128, 128, 128, 128, 128, 128, - 128, 128, 128, 128, 128, 128, 122, 122, 122, 109, 109, 109, 19, 19, 19, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 9, 9, 9, 89, 89, 89, 140, 140, 140, 175, 175, 175, 190, 190, 190, 194, 194, 194, - 194, 194, 194, 194, 194, 194, 193, 193, 193, 187, 187, 187, 168, 168, 168, 64, 64, 64, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 12, 12, 113, 113, 113, 175, 175, 175, 214, 214, 214, 231, 231, - 231, 235, 235, 235, 236, 236, 236, 236, 236, 236, 235, 235, 235, 228, 228, 228, 207, 207, 207, - 80, 80, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 13, 13, 123, 123, 123, 188, 188, 188, 229, - 229, 229, 245, 245, 245, 250, 250, 250, 251, 251, 251, 251, 251, 251, 249, 249, 249, 243, 243, - 243, 221, 221, 221, 86, 86, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 12, 12, 120, 120, 120, - 184, 184, 184, 224, 224, 224, 241, 241, 241, 245, 245, 245, 246, 246, 246, 246, 246, 246, 245, - 245, 245, 238, 238, 238, 217, 217, 217, 85, 85, 85, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, - 1, 103, 103, 103, 160, 160, 160, 198, 198, 198, 214, 214, 214, 218, 218, 218, 220, 220, 220, - 220, 220, 220, 218, 218, 218, 212, 212, 212, 191, 191, 191, 34, 34, 34, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 26, 26, 26, 32, 32, 32, 35, 35, 35, 36, 36, 36, 36, 36, 36, 36, - 36, 36, 36, 36, 36, 35, 35, 35, 10, 10, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -]; - -static FILM_STRIP_32: [u8; 32 * 32 * 3] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 11, 11, - 23, 23, 23, 28, 28, 28, 32, 32, 32, 34, 34, 34, 36, 36, 36, 37, 37, 37, 37, 37, 37, 38, 38, 38, - 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38, 37, 37, 37, 37, 37, 37, 35, 35, 35, - 29, 29, 29, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 28, 28, 28, 54, 54, 54, 69, 69, 69, 83, 83, 83, 93, 93, 93, - 101, 101, 101, 105, 105, 105, 108, 108, 108, 109, 109, 109, 111, 111, 111, 110, 110, 110, 110, - 110, 110, 110, 110, 110, 110, 110, 110, 110, 110, 110, 109, 109, 109, 107, 107, 107, 103, 103, - 103, 97, 97, 97, 88, 88, 88, 13, 13, 13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 11, 11, 54, 54, 54, 74, 74, 74, 93, 93, 93, 110, 110, - 110, 124, 124, 124, 133, 133, 133, 139, 139, 139, 143, 143, 143, 144, 144, 144, 145, 145, 145, - 145, 145, 145, 145, 145, 145, 145, 145, 145, 146, 146, 146, 145, 145, 145, 144, 144, 144, 141, - 141, 141, 136, 136, 136, 129, 129, 129, 118, 118, 118, 88, 88, 88, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 23, 23, 23, 69, 69, 69, 93, 93, - 93, 117, 117, 117, 138, 138, 138, 154, 154, 154, 165, 165, 165, 172, 172, 172, 176, 176, 176, - 178, 178, 178, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 179, 178, - 178, 178, 177, 177, 177, 174, 174, 174, 170, 170, 170, 161, 161, 161, 146, 146, 146, 128, 128, - 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 28, 28, 28, 82, 82, 82, 110, 110, 110, 138, 138, 138, 162, 162, 162, 180, 180, 180, 192, 192, - 192, 200, 200, 200, 204, 204, 204, 206, 206, 206, 207, 207, 207, 207, 207, 207, 207, 207, 207, - 207, 207, 207, 207, 207, 207, 207, 207, 207, 205, 205, 205, 202, 202, 202, 197, 197, 197, 187, - 187, 187, 172, 172, 172, 151, 151, 151, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 32, 32, 92, 92, 92, 124, 124, 124, 154, 154, 154, 180, - 180, 180, 199, 199, 199, 212, 212, 212, 220, 220, 220, 225, 225, 225, 226, 226, 226, 227, 227, - 227, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 227, 227, 227, 226, 226, 226, - 223, 223, 223, 217, 217, 217, 207, 207, 207, 191, 191, 191, 168, 168, 168, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 34, 34, 34, 100, 100, 100, - 134, 134, 134, 165, 165, 165, 192, 192, 192, 212, 212, 212, 226, 226, 226, 234, 234, 234, 238, - 238, 238, 240, 240, 240, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, - 241, 241, 241, 241, 240, 240, 240, 236, 236, 236, 230, 230, 230, 220, 220, 220, 203, 203, 203, - 180, 180, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 36, 36, 36, 104, 104, 104, 138, 138, 138, 171, 171, 171, 199, 199, 199, 219, 219, 219, - 233, 233, 233, 240, 240, 240, 245, 245, 245, 247, 247, 247, 248, 248, 248, 248, 248, 248, 248, - 248, 248, 248, 248, 248, 248, 248, 248, 247, 247, 247, 246, 246, 246, 243, 243, 243, 237, 237, - 237, 227, 227, 227, 210, 210, 210, 186, 186, 186, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 36, 36, 105, 105, 105, 140, 140, 140, 173, - 173, 173, 201, 201, 201, 222, 222, 222, 235, 235, 235, 243, 243, 243, 248, 248, 248, 250, 250, - 250, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 251, 250, 250, 250, - 249, 249, 249, 246, 246, 246, 240, 240, 240, 229, 229, 229, 212, 212, 212, 188, 188, 188, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 36, 36, - 104, 104, 104, 138, 138, 138, 171, 171, 171, 199, 199, 199, 219, 219, 219, 233, 233, 233, 240, - 240, 240, 245, 245, 245, 247, 247, 247, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, - 248, 248, 248, 248, 247, 247, 247, 246, 246, 246, 243, 243, 243, 237, 237, 237, 227, 227, 227, - 210, 210, 210, 186, 186, 186, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 34, 34, 34, 100, 100, 100, 134, 134, 134, 165, 165, 165, 192, 192, 192, - 212, 212, 212, 226, 226, 226, 234, 234, 234, 238, 238, 238, 240, 240, 240, 241, 241, 241, 241, - 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 241, 240, 240, 240, 236, 236, - 236, 230, 230, 230, 220, 220, 220, 203, 203, 203, 180, 180, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 19, 19, 92, 92, 92, 124, 124, - 124, 154, 154, 154, 180, 180, 180, 200, 200, 200, 212, 212, 212, 220, 220, 220, 225, 225, 225, - 226, 226, 226, 227, 227, 227, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 228, 227, - 227, 227, 226, 226, 226, 223, 223, 223, 217, 217, 217, 207, 207, 207, 191, 191, 191, 146, 146, - 146, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 59, 59, 59, 110, 110, 110, 138, 138, 138, 162, 162, 162, 180, 180, 180, 193, 193, 193, - 200, 200, 200, 204, 204, 204, 206, 206, 206, 207, 207, 207, 208, 208, 208, 208, 208, 208, 208, - 208, 208, 208, 208, 208, 207, 207, 207, 205, 205, 205, 203, 203, 203, 197, 197, 197, 187, 187, - 187, 172, 172, 172, 27, 27, 27, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 27, 27, 27, 58, 58, 58, 69, 69, 69, 77, 77, 77, - 83, 83, 83, 86, 86, 86, 88, 88, 88, 89, 89, 89, 89, 89, 89, 90, 90, 90, 90, 90, 90, 90, 90, 90, - 90, 90, 90, 89, 89, 89, 88, 88, 88, 87, 87, 87, 85, 85, 85, 70, 70, 70, 8, 8, 8, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, -]; - -static FILM_STRIP_64: [u8; 64 * 64 * 3] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 16, 34, 34, 34, 47, - 47, 47, 54, 54, 54, 59, 59, 59, 64, 64, 64, 68, 68, 68, 72, 72, 72, 75, 75, 75, 77, 77, 77, 79, - 79, 79, 81, 81, 81, 82, 82, 82, 82, 82, 82, 83, 83, 83, 83, 83, 83, 84, 84, 84, 84, 84, 84, 84, - 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, 84, - 84, 84, 83, 83, 83, 83, 83, 83, 82, 82, 82, 82, 82, 82, 81, 81, 81, 79, 79, 79, 77, 77, 77, 72, - 72, 72, 57, 57, 57, 30, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 30, 30, 30, 46, 46, - 46, 52, 52, 52, 59, 59, 59, 66, 66, 66, 72, 72, 72, 78, 78, 78, 82, 82, 82, 87, 87, 87, 90, 90, - 90, 93, 93, 93, 95, 95, 95, 97, 97, 97, 98, 98, 98, 99, 99, 99, 100, 100, 100, 100, 100, 100, - 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, - 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 100, 100, 100, 100, 100, - 100, 99, 99, 99, 98, 98, 98, 97, 97, 97, 95, 95, 95, 93, 93, 93, 90, 90, 90, 87, 87, 87, 82, - 82, 82, 61, 61, 61, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 30, 30, 47, 47, 47, 55, 55, 55, 63, 63, 63, - 71, 71, 71, 78, 78, 78, 86, 86, 86, 92, 92, 92, 98, 98, 98, 103, 103, 103, 107, 107, 107, 110, - 110, 110, 112, 112, 112, 114, 114, 114, 116, 116, 116, 117, 117, 117, 118, 118, 118, 118, 118, - 118, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, - 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 118, 118, 118, 118, - 118, 118, 117, 117, 117, 116, 116, 116, 114, 114, 114, 113, 113, 113, 110, 110, 110, 107, 107, - 107, 103, 103, 103, 98, 98, 98, 92, 92, 92, 67, 67, 67, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 16, 16, 46, 46, 46, 54, 54, - 54, 64, 64, 64, 73, 73, 73, 82, 82, 82, 91, 91, 91, 99, 99, 99, 106, 106, 106, 113, 113, 113, - 118, 118, 118, 123, 123, 123, 126, 126, 126, 129, 129, 129, 131, 131, 131, 133, 133, 133, 134, - 134, 134, 135, 135, 135, 135, 135, 135, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, - 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, - 136, 136, 136, 135, 135, 135, 135, 135, 135, 134, 134, 134, 133, 133, 133, 131, 131, 131, 129, - 129, 129, 126, 126, 126, 123, 123, 123, 118, 118, 118, 113, 113, 113, 106, 106, 106, 99, 99, - 99, 41, 41, 41, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 34, 34, 34, 52, 52, 52, 62, 62, 62, 73, 73, 73, 83, 83, 83, 94, 94, 94, 104, - 104, 104, 113, 113, 113, 121, 121, 121, 128, 128, 128, 134, 134, 134, 139, 139, 139, 143, 143, - 143, 146, 146, 146, 149, 149, 149, 150, 150, 150, 152, 152, 152, 152, 152, 152, 153, 153, 153, - 153, 153, 153, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, - 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 153, 153, 153, 153, 153, 153, 153, 153, - 153, 152, 152, 152, 150, 150, 150, 149, 149, 149, 146, 146, 146, 143, 143, 143, 139, 139, 139, - 134, 134, 134, 128, 128, 128, 121, 121, 121, 113, 113, 113, 82, 82, 82, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 47, 47, 59, 59, 59, - 71, 71, 71, 82, 82, 82, 94, 94, 94, 105, 105, 105, 116, 116, 116, 126, 126, 126, 135, 135, 135, - 143, 143, 143, 150, 150, 150, 155, 155, 155, 159, 159, 159, 163, 163, 163, 165, 165, 165, 167, - 167, 167, 169, 169, 169, 169, 169, 169, 170, 170, 170, 170, 170, 170, 171, 171, 171, 171, 171, - 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, - 171, 171, 171, 170, 170, 170, 170, 170, 170, 169, 169, 169, 169, 169, 169, 167, 167, 167, 165, - 165, 165, 163, 163, 163, 160, 160, 160, 155, 155, 155, 150, 150, 150, 143, 143, 143, 135, 135, - 135, 126, 126, 126, 111, 111, 111, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 54, 54, 54, 66, 66, 66, 78, 78, 78, 91, 91, 91, 104, 104, 104, - 116, 116, 116, 128, 128, 128, 139, 139, 139, 148, 148, 148, 157, 157, 157, 164, 164, 164, 169, - 169, 169, 174, 174, 174, 178, 178, 178, 180, 180, 180, 182, 182, 182, 183, 183, 183, 184, 184, - 184, 185, 185, 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, - 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 186, 185, - 185, 185, 184, 184, 184, 184, 184, 184, 182, 182, 182, 180, 180, 180, 178, 178, 178, 174, 174, - 174, 170, 170, 170, 164, 164, 164, 157, 157, 157, 148, 148, 148, 139, 139, 139, 128, 128, 128, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 59, 59, 59, 72, 72, 72, 85, 85, 85, 99, 99, 99, 113, 113, 113, 126, 126, 126, 139, 139, 139, - 150, 150, 150, 161, 161, 161, 169, 169, 169, 177, 177, 177, 183, 183, 183, 188, 188, 188, 191, - 191, 191, 194, 194, 194, 196, 196, 196, 197, 197, 197, 198, 198, 198, 199, 199, 199, 199, 199, - 199, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, - 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 199, 199, 199, 198, 198, 198, 198, - 198, 198, 196, 196, 196, 194, 194, 194, 191, 191, 191, 188, 188, 188, 183, 183, 183, 177, 177, - 177, 169, 169, 169, 161, 161, 161, 150, 150, 150, 139, 139, 139, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 64, 64, 77, 77, 77, 92, 92, - 92, 106, 106, 106, 121, 121, 121, 135, 135, 135, 149, 149, 149, 161, 161, 161, 172, 172, 172, - 181, 181, 181, 189, 189, 189, 195, 195, 195, 200, 200, 200, 204, 204, 204, 207, 207, 207, 209, - 209, 209, 210, 210, 210, 211, 211, 211, 212, 212, 212, 212, 212, 212, 213, 213, 213, 213, 213, - 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, - 213, 213, 213, 213, 213, 213, 212, 212, 212, 211, 211, 211, 210, 210, 210, 209, 209, 209, 207, - 207, 207, 204, 204, 204, 200, 200, 200, 195, 195, 195, 189, 189, 189, 181, 181, 181, 172, 172, - 172, 161, 161, 161, 149, 149, 149, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 68, 68, 68, 82, 82, 82, 97, 97, 97, 113, 113, 113, 128, 128, - 128, 143, 143, 143, 157, 157, 157, 170, 170, 170, 181, 181, 181, 190, 190, 190, 199, 199, 199, - 205, 205, 205, 210, 210, 210, 214, 214, 214, 217, 217, 217, 219, 219, 219, 220, 220, 220, 221, - 221, 221, 222, 222, 222, 222, 222, 222, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, - 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, - 222, 222, 222, 221, 221, 221, 220, 220, 220, 219, 219, 219, 217, 217, 217, 214, 214, 214, 210, - 210, 210, 205, 205, 205, 199, 199, 199, 191, 191, 191, 181, 181, 181, 170, 170, 170, 157, 157, - 157, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 72, 72, 72, 87, 87, 87, 102, 102, 102, 118, 118, 118, 134, 134, 134, 150, 150, 150, 164, - 164, 164, 177, 177, 177, 189, 189, 189, 198, 198, 198, 207, 207, 207, 213, 213, 213, 218, 218, - 218, 222, 222, 222, 225, 225, 225, 227, 227, 227, 229, 229, 229, 229, 229, 229, 230, 230, 230, - 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, - 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 230, 230, 230, 230, 230, - 230, 229, 229, 229, 227, 227, 227, 225, 225, 225, 222, 222, 222, 218, 218, 218, 213, 213, 213, - 207, 207, 207, 198, 198, 198, 189, 189, 189, 177, 177, 177, 164, 164, 164, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 75, 75, 75, 90, 90, 90, - 106, 106, 106, 123, 123, 123, 139, 139, 139, 155, 155, 155, 170, 170, 170, 183, 183, 183, 195, - 195, 195, 205, 205, 205, 213, 213, 213, 220, 220, 220, 225, 225, 225, 229, 229, 229, 232, 232, - 232, 234, 234, 234, 236, 236, 236, 236, 236, 236, 237, 237, 237, 238, 238, 238, 238, 238, 238, - 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, - 238, 238, 238, 238, 238, 238, 238, 238, 237, 237, 237, 237, 237, 237, 236, 236, 236, 234, 234, - 234, 232, 232, 232, 229, 229, 229, 225, 225, 225, 220, 220, 220, 213, 213, 213, 205, 205, 205, - 195, 195, 195, 183, 183, 183, 170, 170, 170, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 77, 77, 93, 93, 93, 109, 109, 109, 126, 126, 126, - 143, 143, 143, 159, 159, 159, 174, 174, 174, 188, 188, 188, 200, 200, 200, 210, 210, 210, 218, - 218, 218, 225, 225, 225, 230, 230, 230, 234, 234, 234, 237, 237, 237, 239, 239, 239, 241, 241, - 241, 242, 242, 242, 242, 242, 242, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, - 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, - 243, 243, 242, 242, 242, 242, 242, 242, 241, 241, 241, 239, 239, 239, 237, 237, 237, 234, 234, - 234, 230, 230, 230, 225, 225, 225, 218, 218, 218, 210, 210, 210, 200, 200, 200, 188, 188, 188, - 174, 174, 174, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 78, 78, 78, 94, 94, 94, 111, 111, 111, 128, 128, 128, 145, 145, 145, 162, 162, 162, - 177, 177, 177, 191, 191, 191, 203, 203, 203, 213, 213, 213, 221, 221, 221, 228, 228, 228, 233, - 233, 233, 237, 237, 237, 240, 240, 240, 242, 242, 242, 244, 244, 244, 245, 245, 245, 245, 245, - 245, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, - 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 245, 245, 245, 245, - 245, 245, 244, 244, 244, 242, 242, 242, 240, 240, 240, 237, 237, 237, 233, 233, 233, 228, 228, - 228, 221, 221, 221, 213, 213, 213, 203, 203, 203, 191, 191, 191, 177, 177, 177, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 80, 80, 80, 96, - 96, 96, 113, 113, 113, 130, 130, 130, 147, 147, 147, 164, 164, 164, 179, 179, 179, 193, 193, - 193, 205, 205, 205, 215, 215, 215, 224, 224, 224, 230, 230, 230, 236, 236, 236, 239, 239, 239, - 242, 242, 242, 244, 244, 244, 246, 246, 246, 247, 247, 247, 247, 247, 247, 248, 248, 248, 248, - 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, - 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 247, 247, 247, 247, 247, 247, 246, 246, 246, - 244, 244, 244, 242, 242, 242, 240, 240, 240, 236, 236, 236, 230, 230, 230, 224, 224, 224, 215, - 215, 215, 205, 205, 205, 193, 193, 193, 179, 179, 179, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 80, 80, 80, 96, 96, 96, 113, 113, 113, - 131, 131, 131, 148, 148, 148, 165, 165, 165, 180, 180, 180, 194, 194, 194, 206, 206, 206, 217, - 217, 217, 225, 225, 225, 232, 232, 232, 237, 237, 237, 241, 241, 241, 244, 244, 244, 246, 246, - 246, 248, 248, 248, 249, 249, 249, 249, 249, 249, 250, 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 250, 250, 250, 249, 249, 249, 249, 249, 249, 248, 248, 248, 246, 246, 246, 244, 244, - 244, 241, 241, 241, 237, 237, 237, 232, 232, 232, 225, 225, 225, 217, 217, 217, 206, 206, 206, - 194, 194, 194, 180, 180, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 80, 80, 80, 96, 96, 96, 113, 113, 113, 131, 131, 131, 148, 148, 148, - 165, 165, 165, 180, 180, 180, 194, 194, 194, 206, 206, 206, 217, 217, 217, 225, 225, 225, 232, - 232, 232, 237, 237, 237, 241, 241, 241, 244, 244, 244, 246, 246, 246, 248, 248, 248, 249, 249, - 249, 249, 249, 249, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, - 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 250, 249, - 249, 249, 249, 249, 249, 248, 248, 248, 246, 246, 246, 244, 244, 244, 241, 241, 241, 237, 237, - 237, 232, 232, 232, 225, 225, 225, 217, 217, 217, 206, 206, 206, 194, 194, 194, 180, 180, 180, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 80, 80, 80, 96, 96, 96, 113, 113, 113, 130, 130, 130, 147, 147, 147, 164, 164, 164, 179, 179, - 179, 193, 193, 193, 205, 205, 205, 215, 215, 215, 224, 224, 224, 230, 230, 230, 236, 236, 236, - 239, 239, 239, 242, 242, 242, 244, 244, 244, 246, 246, 246, 247, 247, 247, 247, 247, 247, 248, - 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, - 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 248, 247, 247, 247, 247, 247, 247, - 246, 246, 246, 244, 244, 244, 242, 242, 242, 240, 240, 240, 236, 236, 236, 230, 230, 230, 224, - 224, 224, 215, 215, 215, 205, 205, 205, 193, 193, 193, 179, 179, 179, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 78, 78, 78, 94, 94, 94, - 111, 111, 111, 128, 128, 128, 145, 145, 145, 162, 162, 162, 177, 177, 177, 191, 191, 191, 203, - 203, 203, 213, 213, 213, 221, 221, 221, 228, 228, 228, 233, 233, 233, 237, 237, 237, 240, 240, - 240, 242, 242, 242, 244, 244, 244, 245, 245, 245, 245, 245, 245, 246, 246, 246, 246, 246, 246, - 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, 246, - 246, 246, 246, 246, 246, 246, 246, 246, 245, 245, 245, 245, 245, 245, 244, 244, 244, 242, 242, - 242, 240, 240, 240, 237, 237, 237, 233, 233, 233, 228, 228, 228, 221, 221, 221, 213, 213, 213, - 203, 203, 203, 191, 191, 191, 177, 177, 177, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 77, 77, 93, 93, 93, 109, 109, 109, 126, 126, 126, - 143, 143, 143, 159, 159, 159, 174, 174, 174, 188, 188, 188, 200, 200, 200, 210, 210, 210, 218, - 218, 218, 225, 225, 225, 230, 230, 230, 234, 234, 234, 237, 237, 237, 239, 239, 239, 241, 241, - 241, 242, 242, 242, 242, 242, 242, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, - 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, 243, - 243, 243, 242, 242, 242, 242, 242, 242, 241, 241, 241, 239, 239, 239, 237, 237, 237, 234, 234, - 234, 230, 230, 230, 225, 225, 225, 218, 218, 218, 210, 210, 210, 200, 200, 200, 188, 188, 188, - 174, 174, 174, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 72, 72, 72, 90, 90, 90, 106, 106, 106, 123, 123, 123, 139, 139, 139, 155, 155, 155, - 170, 170, 170, 183, 183, 183, 195, 195, 195, 205, 205, 205, 213, 213, 213, 220, 220, 220, 225, - 225, 225, 229, 229, 229, 232, 232, 232, 234, 234, 234, 236, 236, 236, 236, 236, 236, 237, 237, - 237, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, - 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 237, 237, 237, 237, - 237, 237, 236, 236, 236, 234, 234, 234, 232, 232, 232, 229, 229, 229, 225, 225, 225, 220, 220, - 220, 213, 213, 213, 205, 205, 205, 195, 195, 195, 183, 183, 183, 163, 163, 163, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 57, 57, 57, 87, - 87, 87, 102, 102, 102, 118, 118, 118, 134, 134, 134, 150, 150, 150, 164, 164, 164, 177, 177, - 177, 189, 189, 189, 198, 198, 198, 207, 207, 207, 213, 213, 213, 218, 218, 218, 222, 222, 222, - 225, 225, 225, 227, 227, 227, 229, 229, 229, 229, 229, 229, 230, 230, 230, 231, 231, 231, 231, - 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, - 231, 231, 231, 231, 231, 231, 231, 231, 231, 231, 230, 230, 230, 230, 230, 230, 229, 229, 229, - 227, 227, 227, 225, 225, 225, 222, 222, 222, 218, 218, 218, 213, 213, 213, 207, 207, 207, 198, - 198, 198, 189, 189, 189, 177, 177, 177, 129, 129, 129, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 30, 30, 82, 82, 82, 97, 97, 97, 113, - 113, 113, 128, 128, 128, 143, 143, 143, 157, 157, 157, 170, 170, 170, 181, 181, 181, 191, 191, - 191, 199, 199, 199, 205, 205, 205, 210, 210, 210, 214, 214, 214, 217, 217, 217, 219, 219, 219, - 220, 220, 220, 221, 221, 221, 222, 222, 222, 222, 222, 222, 223, 223, 223, 223, 223, 223, 223, - 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, 223, - 223, 223, 223, 223, 222, 222, 222, 221, 221, 221, 221, 221, 221, 219, 219, 219, 217, 217, 217, - 214, 214, 214, 210, 210, 210, 205, 205, 205, 199, 199, 199, 191, 191, 191, 181, 181, 181, 170, - 170, 170, 71, 71, 71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 61, 61, 61, 92, 92, 92, 106, 106, 106, 121, 121, 121, 135, 135, - 135, 149, 149, 149, 161, 161, 161, 172, 172, 172, 181, 181, 181, 189, 189, 189, 195, 195, 195, - 200, 200, 200, 204, 204, 204, 207, 207, 207, 209, 209, 209, 210, 210, 210, 211, 211, 211, 212, - 212, 212, 212, 212, 212, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, - 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 213, 212, 212, 212, - 211, 211, 211, 210, 210, 210, 209, 209, 209, 207, 207, 207, 204, 204, 204, 200, 200, 200, 195, - 195, 195, 189, 189, 189, 181, 181, 181, 172, 172, 172, 126, 126, 126, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, - 67, 67, 67, 99, 99, 99, 113, 113, 113, 126, 126, 126, 139, 139, 139, 151, 151, 151, 161, 161, - 161, 170, 170, 170, 177, 177, 177, 184, 184, 184, 188, 188, 188, 192, 192, 192, 195, 195, 195, - 197, 197, 197, 198, 198, 198, 199, 199, 199, 200, 200, 200, 200, 200, 200, 201, 201, 201, 201, - 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, - 201, 201, 201, 201, 200, 200, 200, 200, 200, 200, 199, 199, 199, 198, 198, 198, 197, 197, 197, - 195, 195, 195, 192, 192, 192, 189, 189, 189, 184, 184, 184, 178, 178, 178, 170, 170, 170, 126, - 126, 126, 18, 18, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 41, 41, 41, 82, 82, 82, 111, 111, 111, - 128, 128, 128, 139, 139, 139, 149, 149, 149, 157, 157, 157, 164, 164, 164, 170, 170, 170, 175, - 175, 175, 178, 178, 178, 181, 181, 181, 183, 183, 183, 184, 184, 184, 185, 185, 185, 186, 186, - 186, 186, 186, 186, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, - 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 186, 186, 186, 186, 186, 186, 185, - 185, 185, 184, 184, 184, 183, 183, 183, 181, 181, 181, 178, 178, 178, 175, 175, 175, 163, 163, - 163, 129, 129, 129, 71, 71, 71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, -]; - -struct FilmStrip { - width: u32, - height: u32, - strip: Option<&'static [u8]>, -} - -pub fn film_strip_filter(video_frame: &mut VideoFrame) { - let FilmStrip { - width, - height, - strip, - } = determine_film_strip(video_frame.width); - - if let Some(strip) = strip { - let mut frame_index = 0; - let mut film_hole_index = 0; - let offset = ((video_frame.width * 3) - 3) as usize; - - for i in 0..(video_frame.height as usize) { - for j in (0..(width as usize * 3)).step_by(3) { - let current_stripe_index = film_hole_index + j; - - video_frame.data[frame_index + j] = strip[current_stripe_index]; - video_frame.data[frame_index + j + 1] = strip[current_stripe_index + 1]; - video_frame.data[frame_index + j + 2] = strip[current_stripe_index + 2]; - - video_frame.data[frame_index + offset - j] = strip[current_stripe_index]; - video_frame.data[frame_index + offset - j + 1] = strip[current_stripe_index + 1]; - video_frame.data[frame_index + offset - j + 2] = strip[current_stripe_index + 2]; - } - - frame_index += video_frame.line_size as usize; - film_hole_index = (i % height as usize) * width as usize * 3; - } - } -} - -fn determine_film_strip(video_width: u32) -> FilmStrip { - match video_width { - // We consider that the smallest film strip is 4, doubling it for each side, we have 8 pixels - 0..=8 => FilmStrip { - width: 0, - height: 0, - strip: None, - }, - 9..=96 => FilmStrip { - width: 4, - height: 4, - strip: Some(&FILM_STRIP_4), - }, - 97..=192 => FilmStrip { - width: 8, - height: 8, - strip: Some(&FILM_STRIP_8), - }, - 193..=384 => FilmStrip { - width: 16, - height: 16, - strip: Some(&FILM_STRIP_16), - }, - 385..=768 => FilmStrip { - width: 32, - height: 32, - strip: Some(&FILM_STRIP_32), - }, - _ => FilmStrip { - width: 64, - height: 64, - strip: Some(&FILM_STRIP_64), - }, - } -} diff --git a/crates/ffmpeg/src/filter_graph.rs b/crates/ffmpeg/src/filter_graph.rs new file mode 100644 index 000000000..05672a762 --- /dev/null +++ b/crates/ffmpeg/src/filter_graph.rs @@ -0,0 +1,256 @@ +use std::{ + ffi::{CStr, CString}, + ptr, +}; + +use crate::{ + codec_ctx::FFmpegCodecContext, error::FFmpegError, frame_decoder::ThumbnailSize, + utils::check_error, Error, +}; +use ffmpeg_sys_next::{ + avfilter_get_by_name, avfilter_graph_alloc, avfilter_graph_config, + avfilter_graph_create_filter, avfilter_graph_free, avfilter_link, AVFilterContext, + AVFilterGraph, AVRational, +}; + +pub struct FFmpegFilterGraph(*mut AVFilterGraph); + +impl<'a> FFmpegFilterGraph { + pub(crate) fn new() -> Result { + let ptr = unsafe { avfilter_graph_alloc() }; + if ptr.is_null() { + return Err(FFmpegError::FrameAllocation); + } + Ok(Self(ptr)) + } + + fn link( + src: *mut AVFilterContext, + src_pad: u32, + dst: *mut AVFilterContext, + dst_pad: u32, + error: &str, + ) -> Result<(), Error> { + check_error(unsafe { avfilter_link(src, src_pad, dst, dst_pad) }, error)?; + Ok(()) + } + + pub(crate) fn thumbnail_graph( + size: Option, + time_base: &AVRational, + codec_ctx: &FFmpegCodecContext, + interlaced_frame: bool, + pixel_aspect_ratio: AVRational, + maintain_aspect_ratio: bool, + ) -> Result<(Self, &'a mut AVFilterContext, &'a mut AVFilterContext), Error> { + let mut filter_graph = Self::new()?; + + let args = format!( + "video_size={}x{}:pix_fmt={}:time_base={}/{}:pixel_aspect={}/{}", + codec_ctx.as_ref().width, + codec_ctx.as_ref().height, + // AVPixelFormat is an i32 enum, so it's safe to cast it to i32 + codec_ctx.as_ref().pix_fmt as i32, + time_base.num, + time_base.den, + codec_ctx.as_ref().sample_aspect_ratio.num, + i32::max(codec_ctx.as_ref().sample_aspect_ratio.den, 1) + ); + + let mut filter_source = ptr::null_mut(); + filter_graph.setup_filter( + &mut filter_source, + c"buffer", + c"thumb_buffer", + Some(CString::new(args)?.as_c_str()), + "Failed to create filter source", + )?; + let filter_source_ctx = unsafe { filter_source.as_mut() }.ok_or(FFmpegError::NullError)?; + + let mut filter_sink = ptr::null_mut(); + filter_graph.setup_filter( + &mut filter_sink, + c"buffersink", + c"thumb_buffersink", + None, + "Failed to create filter sink", + )?; + let filter_sink_ctx = unsafe { filter_sink.as_mut() }.ok_or(FFmpegError::NullError)?; + + let mut yadif_filter = ptr::null_mut(); + if interlaced_frame { + filter_graph.setup_filter( + &mut yadif_filter, + c"yadif", + c"thumb_deint", + Some(c"deint=1"), + "Failed to create de-interlace filter", + )?; + } + + let mut scale_filter = ptr::null_mut(); + filter_graph.setup_filter( + &mut scale_filter, + c"scale", + c"thumb_scale", + Some( + CString::new(thumb_scale_filter_args( + size, + codec_ctx, + pixel_aspect_ratio, + maintain_aspect_ratio, + ))? + .as_c_str(), + ), + "Failed to create scale filter", + )?; + + let mut format_filter = ptr::null_mut(); + filter_graph.setup_filter( + &mut format_filter, + c"format", + c"thumb_format", + Some(c"pix_fmts=rgb24"), + "Failed to create format filter", + )?; + + Self::link( + format_filter, + 0, + filter_sink_ctx, + 0, + "Failed to link final filter", + )?; + + Self::link( + scale_filter, + 0, + format_filter, + 0, + "Failed to link scale filter", + )?; + + if !yadif_filter.is_null() { + Self::link( + yadif_filter, + 0, + scale_filter, + 0, + "Failed to link yadif filter", + )?; + } + + Self::link( + filter_source_ctx, + 0, + if yadif_filter.is_null() { + scale_filter + } else { + yadif_filter + }, + 0, + "Failed to link source filter", + )?; + + filter_graph.config()?; + + Ok((filter_graph, filter_source_ctx, filter_sink_ctx)) + } + + pub(crate) fn as_mut(&mut self) -> &mut AVFilterGraph { + unsafe { self.0.as_mut() }.expect("initialized on struct creation") + } + + fn setup_filter( + &mut self, + filter_ctx: *mut *mut AVFilterContext, + filter_name: &CStr, + filter_setup_name: &CStr, + args: Option<&CStr>, + error_message: &str, + ) -> Result<(), Error> { + check_error( + unsafe { + avfilter_graph_create_filter( + filter_ctx, + avfilter_get_by_name(filter_name.as_ptr()), + filter_setup_name.as_ptr(), + args.map_or(ptr::null(), CStr::as_ptr), + ptr::null_mut(), + self.as_mut(), + ) + }, + error_message, + ) + } + + fn config(&mut self) -> Result<&mut Self, Error> { + check_error( + unsafe { avfilter_graph_config(self.as_mut(), ptr::null_mut()) }, + "Failed to configure filter graph", + )?; + + Ok(self) + } +} + +impl Drop for FFmpegFilterGraph { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { avfilter_graph_free(&mut self.0) }; + self.0 = ptr::null_mut(); + } + } +} + +fn thumb_scale_filter_args( + size: Option, + codec_ctx: &FFmpegCodecContext, + pixel_aspect_ratio: AVRational, + maintain_aspect_ratio: bool, +) -> String { + let (width, height) = match size { + Some(ThumbnailSize::Dimensions { width, height }) => (width, Some(height)), + Some(ThumbnailSize::Scale(width)) => (width, None), + None => return "w=0:h=0".to_string(), + }; + + let mut scale = String::new(); + + if let Some(height) = height { + scale.push_str(&format!("w={width}:h={height}")); + if maintain_aspect_ratio { + scale.push_str(":force_original_aspect_ratio=decrease"); + } + } else if !maintain_aspect_ratio { + scale.push_str(&format!("w={width}:h={width}")); + } else { + let size = width; + let mut width = codec_ctx.as_ref().width.unsigned_abs(); + let mut height = codec_ctx.as_ref().height.unsigned_abs(); + + // if the pixel aspect ratio is defined and is not 1, we have an anamorphic stream + if pixel_aspect_ratio.num != 0 && pixel_aspect_ratio.num != pixel_aspect_ratio.den { + width = (width * pixel_aspect_ratio.num.unsigned_abs()) + / pixel_aspect_ratio.den.unsigned_abs(); + + if size != 0 { + if height > width { + width = (width * size) / height; + height = size; + } else { + height = (height * size) / width; + width = size; + } + } + + scale.push_str(&format!("w={width}:h={height}")); + } else if height > width { + scale.push_str(&format!("w=-1:h={}", if size == 0 { height } else { size })); + } else { + scale.push_str(&format!("h=-1:w={}", if size == 0 { width } else { size })); + } + } + + scale +} diff --git a/crates/ffmpeg/src/format_ctx.rs b/crates/ffmpeg/src/format_ctx.rs new file mode 100644 index 000000000..4d10647e6 --- /dev/null +++ b/crates/ffmpeg/src/format_ctx.rs @@ -0,0 +1,427 @@ +use crate::{ + codec_ctx::FFmpegCodecContext, + dict::FFmpegDictionary, + error::{Error, FFmpegError}, + model::{FFmpegChapter, FFmpegMediaData, FFmpegMetadata, FFmpegProgram, FFmpegStream}, + utils::check_error, +}; + +use ffmpeg_sys_next::{ + av_cmp_q, av_display_rotation_get, av_read_frame, av_reduce, av_stream_get_side_data, + avformat_close_input, avformat_find_stream_info, avformat_open_input, AVChapter, AVCodecID, + AVDictionary, AVFormatContext, AVMediaType, AVPacket, AVPacketSideDataType, AVRational, + AVStream, AV_DISPOSITION_ATTACHED_PIC, AV_DISPOSITION_CAPTIONS, AV_DISPOSITION_CLEAN_EFFECTS, + AV_DISPOSITION_COMMENT, AV_DISPOSITION_DEFAULT, AV_DISPOSITION_DEPENDENT, + AV_DISPOSITION_DESCRIPTIONS, AV_DISPOSITION_DUB, AV_DISPOSITION_FORCED, + AV_DISPOSITION_HEARING_IMPAIRED, AV_DISPOSITION_KARAOKE, AV_DISPOSITION_LYRICS, + AV_DISPOSITION_METADATA, AV_DISPOSITION_NON_DIEGETIC, AV_DISPOSITION_ORIGINAL, + AV_DISPOSITION_STILL_IMAGE, AV_DISPOSITION_TIMED_THUMBNAILS, AV_DISPOSITION_VISUAL_IMPAIRED, + AV_NOPTS_VALUE, +}; + +use std::{collections::HashSet, ffi::CStr, ptr}; + +fn extract_name_and_convert_metadata( + metadata: *mut AVDictionary, +) -> (FFmpegMetadata, Option) { + let mut metadata = FFmpegDictionary::new(unsafe { metadata.as_mut() }); + let name = metadata.get(c"name"); + if name.is_some() { + let _ = metadata.remove(c"name"); + } + + (metadata.into(), name) +} + +#[derive(Debug)] +pub struct FFmpegFormatContext(*mut AVFormatContext); + +impl FFmpegFormatContext { + pub(crate) fn open_file(filename: &CStr) -> Result { + let mut ptr = ptr::null_mut(); + + check_error( + unsafe { + avformat_open_input(&mut ptr, filename.as_ptr(), ptr::null(), ptr::null_mut()) + }, + "Fail to open an input stream and read the header", + ) + .map(|()| Self(ptr)) + } + + pub(crate) fn as_ref(&self) -> &AVFormatContext { + unsafe { self.0.as_ref() }.expect("initialized on struct creation") + } + + pub(crate) fn as_mut(&mut self) -> &mut AVFormatContext { + unsafe { self.0.as_mut() }.expect("initialized on struct creation") + } + + pub(crate) fn duration(&self) -> Option { + let duration = self.as_ref().duration; + if duration == AV_NOPTS_VALUE { + return None; + } + + Some(duration) + } + + pub(crate) fn stream(&self, index: u32) -> Option<&mut AVStream> { + let streams = self.as_ref().streams; + if streams.is_null() { + return None; + } + + let Ok(index) = isize::try_from(index) else { + return None; + }; + + unsafe { (*(streams.offset(index))).as_mut() } + } + + pub(crate) fn get_stream_rotation_angle(&self, index: u32) -> f64 { + let Some(stream) = self.stream(index) else { + return 0.0; + }; + + /* + * This side data contains a 3x3 transformation matrix describing an affine transformation + * that needs to be applied to the decoded video frames for correct presentation. + * + * See libavutil/display.h for a detailed description of the data. + * https://github.com/FFmpeg/FFmpeg/blob/n6.1.1/libavutil/display.h#L32-L71 + * + * The pointer conversion is due to the fact that av_stream_get_side_data is a generic function that has no prior + * knowledge of the type of the side data it is retrieving. + */ + #[allow(clippy::cast_ptr_alignment)] + let matrix = (unsafe { + av_stream_get_side_data( + stream, + AVPacketSideDataType::AV_PKT_DATA_DISPLAYMATRIX, + ptr::null_mut(), + ) + } as *const i32); + + if matrix.is_null() { + 0.0 + } else { + unsafe { av_display_rotation_get(matrix) } + } + } + + pub(crate) fn read_frame(&mut self, packet: *mut AVPacket) -> Result<&mut Self, Error> { + check_error( + unsafe { av_read_frame(self.as_mut(), packet) }, + "Fail to read the next frame of a media file", + )?; + + Ok(self) + } + + pub(crate) fn find_stream_info(&mut self) -> Result<&mut Self, Error> { + check_error( + unsafe { avformat_find_stream_info(self.as_mut(), ptr::null_mut()) }, + "Fail to read packets of a media file to get stream information", + )?; + + Ok(self) + } + + pub(crate) fn find_preferred_video_stream( + &self, + prefer_embedded_metadata: bool, + ) -> Result<(bool, &mut AVStream), Error> { + let mut video_streams = vec![]; + let mut embedded_data_streams = vec![]; + + 'outer: for stream_idx in 0..self.as_ref().nb_streams { + let Some(stream) = self.stream(stream_idx) else { + continue; + }; + + let Some((codec_type, codec_id)) = unsafe { stream.codecpar.as_ref() } + .map(|codec_params| (codec_params.codec_type, codec_params.codec_id)) + else { + continue; + }; + + if codec_type != AVMediaType::AVMEDIA_TYPE_VIDEO { + continue; + } + + if !prefer_embedded_metadata + || !(codec_id == AVCodecID::AV_CODEC_ID_MJPEG + || codec_id == AVCodecID::AV_CODEC_ID_PNG) + { + video_streams.push(stream_idx); + continue; + } + + if let Some(metadata) = unsafe { stream.metadata.as_mut() } + .map(|metadata| FFmpegDictionary::new(Some(metadata))) + { + for (key, value) in &metadata { + if let Some(value) = value { + if key == "filename" && value.starts_with("cover.") { + embedded_data_streams.insert(0, stream_idx); + continue 'outer; + } + } + } + } + + embedded_data_streams.push(stream_idx); + } + + if prefer_embedded_metadata && !embedded_data_streams.is_empty() { + for stream_index in embedded_data_streams { + if let Some(stream) = self.stream(stream_index) { + return Ok((true, stream)); + } + } + } + + for stream_index in video_streams { + if let Some(stream) = self.stream(stream_index) { + return Ok((false, stream)); + } + } + + Err(FFmpegError::StreamNotFound)? + } + + fn formats(&self) -> Vec { + unsafe { self.as_ref().iformat.as_ref() } + .and_then(|format| unsafe { format.name.as_ref() }) + .map(|name| { + let cstr = unsafe { CStr::from_ptr(name) }; + String::from_utf8_lossy(cstr.to_bytes()) + .split(',') + .map(|entry| entry.trim().to_string()) + .filter(|entry| !entry.is_empty()) + .collect() + }) + .unwrap_or(vec![]) + } + + fn start_time(&self) -> Option { + let start_time = self.as_ref().start_time; + if start_time == AV_NOPTS_VALUE { + return None; + } + + Some(start_time) + } + + fn bit_rate(&self) -> i64 { + self.as_ref().bit_rate + } + + fn chapters(&self) -> Vec { + let chapters_ptr = self.as_ref().chapters; + (!chapters_ptr.is_null()) + .then(|| { + (0..isize::try_from(self.as_ref().nb_chapters).unwrap_or(0)) + .filter_map(|id| unsafe { (*(chapters_ptr.offset(id))).as_ref() }) + .map(Into::into) + .collect() + }) + .unwrap_or(vec![]) + } + + fn programs(&self) -> Vec { + let mut visited_streams: HashSet = HashSet::new(); + let programs_ptr = self.as_ref().programs; + + let mut programs = (!programs_ptr.is_null()) + .then(|| { + (0..isize::try_from(self.as_ref().nb_programs).unwrap_or(0)) + .filter_map(|id| unsafe { (*(programs_ptr.offset(id))).as_ref() }) + .map(|program| { + let (metadata, name) = extract_name_and_convert_metadata(program.metadata); + + let streams = (0..isize::try_from(program.nb_stream_indexes).unwrap_or(0)) + .filter_map(|index| unsafe { + program.stream_index.offset(index).as_ref() + }) + .copied() + .filter_map(|stream_index| { + visited_streams.insert(stream_index); + self.stream(stream_index) + }) + .map(|stream| (&*stream).into()) + .collect::>(); + + FFmpegProgram { + id: program.id, + name, + streams, + metadata, + } + }) + .collect::>() + }) + .unwrap_or(vec![]); + + let unvisited_streams = (0..self.as_ref().nb_streams) + .filter(|i| !visited_streams.contains(i)) + .filter_map(|i| self.stream(i).map(|stream| (&*stream).into())) + .collect::>(); + if !unvisited_streams.is_empty() { + if let Ok(id) = i32::try_from(programs.len()) { + // Create an empty program to hold unvisited streams if there are any + programs.push(FFmpegProgram { + id, + name: Some("No Program".to_string()), + streams: unvisited_streams, + metadata: FFmpegMetadata::default(), + }); + } + } + + programs + } + + fn metadata(&self) -> FFmpegMetadata { + let fmt_ctx = self.as_ref(); + unsafe { fmt_ctx.metadata.as_mut() }.map_or_else(FFmpegMetadata::default, |metadata| { + FFmpegDictionary::new(Some(metadata)).into() + }) + } +} + +impl Drop for FFmpegFormatContext { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { avformat_close_input(&mut self.0) }; + self.0 = ptr::null_mut(); + } + } +} + +impl From<&FFmpegFormatContext> for FFmpegMediaData { + fn from(ctx: &FFmpegFormatContext) -> Self { + Self { + formats: ctx.formats(), + duration: ctx.duration(), + start_time: ctx.start_time(), + bit_rate: ctx.bit_rate(), + chapters: ctx.chapters(), + programs: ctx.programs(), + metadata: ctx.metadata(), + } + } +} + +impl From<&AVChapter> for FFmpegChapter { + fn from( + AVChapter { + id, + time_base, + start, + end, + metadata, + }: &AVChapter, + ) -> Self { + Self { + // NOTICE: chapter.id is a i64, but I think it will be extremely rare to have a chapter id that doesn't fit in a i32 + id: *id, + start: *start, + end: *end, + time_base_num: time_base.num, + time_base_den: time_base.den, + metadata: unsafe { metadata.as_mut() } + .map_or_else(FFmpegMetadata::default, |metadata| { + FFmpegDictionary::new(Some(metadata)).into() + }), + } + } +} + +impl From<&AVStream> for FFmpegStream { + fn from(stream: &AVStream) -> Self { + let (metadata, name) = extract_name_and_convert_metadata(stream.metadata); + + let aspect_ratio = unsafe { stream.codecpar.as_ref() } + .and_then(|codecpar| { + if stream.sample_aspect_ratio.num != 0 + && unsafe { av_cmp_q(stream.sample_aspect_ratio, codecpar.sample_aspect_ratio) } + != 0 + { + let mut display_aspect_ratio = AVRational { num: 0, den: 0 }; + let num = i64::from(codecpar.width * codecpar.sample_aspect_ratio.num); + let den = i64::from(codecpar.height * codecpar.sample_aspect_ratio.den); + let max = 1024 * 1024; + unsafe { + av_reduce( + &mut display_aspect_ratio.num, + &mut display_aspect_ratio.den, + num, + den, + max, + ); + } + + Some(display_aspect_ratio) + } else { + None + } + }) + .unwrap_or(stream.sample_aspect_ratio); + + let dispositions = [ + (AV_DISPOSITION_DEFAULT, "default"), + (AV_DISPOSITION_DUB, "dub"), + (AV_DISPOSITION_ORIGINAL, "original"), + (AV_DISPOSITION_COMMENT, "comment"), + (AV_DISPOSITION_LYRICS, "lyrics"), + (AV_DISPOSITION_KARAOKE, "karaoke"), + (AV_DISPOSITION_FORCED, "forced"), + (AV_DISPOSITION_HEARING_IMPAIRED, "hearing impaired"), + (AV_DISPOSITION_VISUAL_IMPAIRED, "visual impaired"), + (AV_DISPOSITION_CLEAN_EFFECTS, "clean effects"), + (AV_DISPOSITION_ATTACHED_PIC, "attached pic"), + (AV_DISPOSITION_TIMED_THUMBNAILS, "timed thumbnails"), + (AV_DISPOSITION_CAPTIONS, "captions"), + (AV_DISPOSITION_DESCRIPTIONS, "descriptions"), + (AV_DISPOSITION_METADATA, "metadata"), + (AV_DISPOSITION_DEPENDENT, "dependent"), + (AV_DISPOSITION_STILL_IMAGE, "still image"), + (AV_DISPOSITION_NON_DIEGETIC, "non-diegetic"), + ] + .iter() + .filter_map(|&(flag, name)| { + if stream.disposition & flag != 0 { + Some(name.to_string()) + } else { + None + } + }) + .collect::>(); + + let codec = unsafe { stream.codecpar.as_ref() }.and_then(|codec_params| { + FFmpegCodecContext::new() + .and_then(|mut codec| { + codec.parameters_to_context(codec_params)?; + Ok(codec) + }) + .map(|codec| (&codec).into()) + .ok() + }); + + Self { + id: stream.index, + name, + codec, + aspect_ratio_num: aspect_ratio.num, + aspect_ratio_den: aspect_ratio.den, + frames_per_second_num: stream.avg_frame_rate.num, + frames_per_second_den: stream.avg_frame_rate.den, + time_base_real_num: stream.time_base.num, + time_base_real_den: stream.time_base.den, + dispositions, + metadata, + } + } +} diff --git a/crates/ffmpeg/src/frame_decoder.rs b/crates/ffmpeg/src/frame_decoder.rs new file mode 100644 index 000000000..95516b6f2 --- /dev/null +++ b/crates/ffmpeg/src/frame_decoder.rs @@ -0,0 +1,290 @@ +use crate::{ + codec_ctx::FFmpegCodecContext, + error::{Error, FFmpegError}, + filter_graph::FFmpegFilterGraph, + format_ctx::FFmpegFormatContext, + utils::{check_error, from_path}, + video_frame::FFmpegFrame, +}; + +use std::{path::Path, ptr}; + +use ffmpeg_sys_next::{ + av_buffersink_get_frame, av_buffersrc_write_frame, av_frame_alloc, + av_guess_sample_aspect_ratio, av_packet_alloc, av_packet_free, av_packet_unref, av_seek_frame, + avcodec_find_decoder, AVPacket, AVRational, AVStream, AVERROR, AVPROBE_SCORE_MAX, + AV_FRAME_FLAG_INTERLACED, AV_FRAME_FLAG_KEY, AV_TIME_BASE, EAGAIN, +}; + +#[derive(Debug, Clone, Copy)] +pub enum ThumbnailSize { + Scale(u32), + Dimensions { width: u32, height: u32 }, +} + +#[derive(Debug)] +pub struct VideoFrame { + pub data: Vec, + pub width: u32, + pub height: u32, + pub rotation: f64, +} + +pub struct FrameDecoder { + format_ctx: FFmpegFormatContext, + preferred_stream_id: u32, + codec_ctx: FFmpegCodecContext, + frame: FFmpegFrame, + packet: *mut AVPacket, + embedded: bool, + allow_seek: bool, +} + +impl FrameDecoder { + pub(crate) fn new( + filename: impl AsRef, + allow_seek: bool, + prefer_embedded: bool, + ) -> Result { + let filename = filename.as_ref(); + + let mut format_context = FFmpegFormatContext::open_file(from_path(filename)?.as_c_str())?; + + format_context.find_stream_info()?; + + // This needs to remain at 100 or the app will force crash if it comes + // across a video with subtitles or any type of corruption. + if format_context.as_ref().probe_score != AVPROBE_SCORE_MAX { + return Err(Error::CorruptVideo( + filename.to_path_buf().into_boxed_path(), + )); + } + + let (embedded, video_stream) = + format_context.find_preferred_video_stream(prefer_embedded)?; + + let preferred_stream_id = u32::try_from(video_stream.index)?; + + let video_codec = unsafe { video_stream.codecpar.as_ref() } + .and_then(|codecpar| unsafe { avcodec_find_decoder(codecpar.codec_id).as_ref() }) + .ok_or(FFmpegError::DecoderNotFound)?; + + let mut video_codec_context = FFmpegCodecContext::new()?; + video_codec_context.parameters_to_context( + unsafe { video_stream.codecpar.as_ref() }.ok_or(FFmpegError::NullError)?, + )?; + video_codec_context.as_mut().workaround_bugs = 1; + video_codec_context.open2(video_codec)?; + + let frame = unsafe { av_frame_alloc() }; + if frame.is_null() { + Err(FFmpegError::FrameAllocation)?; + } + + Ok(Self { + format_ctx: format_context, + preferred_stream_id, + codec_ctx: video_codec_context, + frame: FFmpegFrame::new()?, + packet: ptr::null_mut(), + allow_seek, + embedded, + }) + } + + pub(crate) fn use_embedded(&mut self) -> bool { + self.embedded + } + + pub(crate) fn decode_video_frame(&mut self) -> Result<(), Error> { + let mut frame_finished = false; + + while !frame_finished && self.find_packet_for_stream() { + frame_finished = self.decode_packet()?; + } + + if !frame_finished { + return Err(Error::FrameDecodeError); + } + + Ok(()) + } + + pub(crate) fn seek(&mut self, seconds: i64) -> Result<(), Error> { + if !self.allow_seek { + return Ok(()); + } + + let timestamp = i64::from(AV_TIME_BASE).checked_mul(seconds).unwrap_or(0); + + check_error( + unsafe { av_seek_frame(self.format_ctx.as_mut(), -1, timestamp, 0) }, + "Seeking video failed", + )?; + + self.codec_ctx.flush(); + + let mut got_frame = false; + for _ in 0..200 { + got_frame = false; + let mut count = 0; + while !got_frame && count < 20 { + self.find_packet_for_stream(); + got_frame = self.decode_packet().unwrap_or(false); + count += 1; + } + + if got_frame && self.frame.as_ref().flags & AV_FRAME_FLAG_KEY != 0 { + break; + } + } + + if got_frame { + Ok(()) + } else { + Err(Error::SeekError) + } + } + + pub(crate) fn get_scaled_video_frame( + &mut self, + size: Option, + maintain_aspect_ratio: bool, + ) -> Result { + let (time_base, stream_ptr) = self + .format_ctx + .stream(self.preferred_stream_id) + .map(|stream| -> (AVRational, *mut AVStream) { (stream.time_base, stream) }) + .ok_or(FFmpegError::NullError)?; + + let pixel_aspect_ratio = unsafe { + av_guess_sample_aspect_ratio(self.format_ctx.as_mut(), stream_ptr, self.frame.as_mut()) + }; + + let (_guard, filter_source, filter_sink) = FFmpegFilterGraph::thumbnail_graph( + size, + &time_base, + &self.codec_ctx, + (self.frame.as_mut().flags & AV_FRAME_FLAG_INTERLACED) != 0, + pixel_aspect_ratio, + maintain_aspect_ratio, + )?; + + let mut new_frame = FFmpegFrame::new()?; + let mut get_frame_errno = 0; + for _ in 0..10 { + check_error( + unsafe { av_buffersrc_write_frame(filter_source, self.frame.as_ref()) }, + "Failed to write frame to filter graph", + )?; + + get_frame_errno = unsafe { av_buffersink_get_frame(filter_sink, new_frame.as_mut()) }; + if get_frame_errno != AVERROR(EAGAIN) { + break; + } + + self.decode_video_frame()?; + } + check_error(get_frame_errno, "Failed to get buffer from filter")?; + + let width = new_frame.as_ref().width.unsigned_abs(); + let height = new_frame.as_ref().height.unsigned_abs(); + let line_size = usize::try_from(new_frame.as_ref().linesize[0])?; + + let mut data = Vec::with_capacity(line_size * usize::try_from(height)?); + data.extend_from_slice(unsafe { + std::slice::from_raw_parts(new_frame.as_ref().data[0], data.capacity()) + }); + + Ok(VideoFrame { + data, + width, + height, + rotation: self + .format_ctx + .get_stream_rotation_angle(self.preferred_stream_id) + .round(), + }) + } + + pub fn get_duration_secs(&self) -> Option { + self.format_ctx.duration().map(|duration| { + let av_time_base = i64::from(AV_TIME_BASE); + #[allow(clippy::cast_precision_loss)] + { + // SAFETY: the duration would need to be humongous for this cast to f64 to cause problems + (duration / av_time_base) as f64 + + ((duration % av_time_base) as f64 / f64::from(AV_TIME_BASE)) + } + }) + } + + fn reset_packet(&mut self) { + if self.packet.is_null() { + self.packet = unsafe { av_packet_alloc() }; + } else { + unsafe { av_packet_unref(self.packet) } + } + } + + fn is_packet_for_stream(&self) -> Option<&mut AVPacket> { + let packet = (unsafe { self.packet.as_mut() })?; + + let packet_stream_id = u32::try_from(packet.stream_index).ok()?; + + if packet_stream_id == self.preferred_stream_id { + Some(packet) + } else { + None + } + } + + fn find_packet_for_stream(&mut self) -> bool { + self.reset_packet(); + while self.format_ctx.read_frame(self.packet).is_ok() { + if self.is_packet_for_stream().is_some() { + return true; + } + + self.reset_packet(); + } + + false + } + + fn decode_packet(&mut self) -> Result { + let Some(packet) = self.is_packet_for_stream() else { + return Ok(false); + }; + + if match self.codec_ctx.send_packet(packet) { + Ok(b) => b, + Err(FFmpegError::Again) => true, + Err(e) => { + return Err(Error::FFmpegWithReason( + e, + "Failed to send packet to decoder".to_string(), + )) + } + } { + match self.codec_ctx.receive_frame(self.frame.as_mut()) { + Ok(ok) => Ok(ok), + Err(FFmpegError::Again) => Ok(false), + Err(e) => Err(Error::FFmpegWithReason( + e, + "Failed to receive frame from decoder".to_string(), + )), + } + } else { + Ok(false) + } + } +} + +impl Drop for FrameDecoder { + fn drop(&mut self) { + unsafe { + av_packet_free(&mut self.packet); + } + } +} diff --git a/crates/ffmpeg/src/lib.rs b/crates/ffmpeg/src/lib.rs index c1170afe0..26eaae419 100644 --- a/crates/ffmpeg/src/lib.rs +++ b/crates/ffmpeg/src/lib.rs @@ -1,30 +1,95 @@ -use crate::{ - film_strip::film_strip_filter, - movie_decoder::{MovieDecoder, ThumbnailSize}, - video_frame::VideoFrame, -}; +#![warn( + clippy::all, + clippy::pedantic, + clippy::correctness, + clippy::perf, + clippy::style, + clippy::suspicious, + clippy::complexity, + clippy::nursery, + clippy::unwrap_used, + unused_qualifications, + rust_2018_idioms, + trivial_casts, + trivial_numeric_casts, + unused_allocation, + clippy::unnecessary_cast, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::dbg_macro, + clippy::deprecated_cfg_attr, + clippy::separated_literal_suffix, + deprecated +)] +#![forbid(deprecated_in_future)] +#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)] + +use crate::{format_ctx::FFmpegFormatContext, frame_decoder::FrameDecoder, utils::from_path}; use std::path::Path; +use ffmpeg_sys_next::{av_log_set_level, AV_LOG_FATAL}; + +mod codec_ctx; +mod dict; mod error; -mod film_strip; -mod movie_decoder; +mod filter_graph; +mod format_ctx; +mod frame_decoder; +pub mod model; mod thumbnailer; mod utils; mod video_frame; pub use error::Error; -pub use thumbnailer::{Thumbnailer, ThumbnailerBuilder}; +pub use frame_decoder::ThumbnailSize; +pub use model::FFmpegMediaData; +pub use thumbnailer::ThumbnailerBuilder; +use tokio::task::spawn_blocking; + +/// Helper function to generate retrieve media data from from a video/audio file +pub async fn probe(filename: impl AsRef + Send) -> Result { + // Reduce the amount of logs generated by FFmpeg + unsafe { av_log_set_level(AV_LOG_FATAL) }; + + // Dictionary to store format options + // let mut format_opts = FFmpegDict::new(None); + // Some MPEGTS specific option (copied from ffprobe) + // let scan_all_pmts = c"scan_all_pmts"; + // format_opts.set(scan_all_pmts, c"1")?; + + // Open an input stream, read the header and allocate the format context + spawn_blocking({ + let filename = filename.as_ref().to_path_buf(); + move || { + let mut fmt_ctx = FFmpegFormatContext::open_file(from_path(filename)?.as_c_str())?; + + // // Reset MPEGTS specific option + // format_opts.remove(scan_all_pmts)?; + + // Read packets of media file to get stream information. + fmt_ctx.find_stream_info()?; + + Ok((&fmt_ctx).into()) + } + }) + .await? +} /// Helper function to generate a thumbnail file from a video file with reasonable defaults pub async fn to_thumbnail( - video_file_path: impl AsRef, - output_thumbnail_path: impl AsRef, - size: u32, + video_file_path: impl AsRef + Send, + output_thumbnail_path: impl AsRef + Send, + size: ThumbnailSize, quality: f32, ) -> Result<(), Error> { + // Reduce the amount of logs generated by FFmpeg + unsafe { av_log_set_level(AV_LOG_FATAL) }; + ThumbnailerBuilder::new() - .with_film_strip(false) .size(size) .quality(quality)? .build() @@ -32,20 +97,6 @@ pub async fn to_thumbnail( .await } -/// Helper function to generate a thumbnail bytes from a video file with reasonable defaults -pub async fn to_webp_bytes( - video_file_path: impl AsRef, - size: u32, - quality: f32, -) -> Result, Error> { - ThumbnailerBuilder::new() - .size(size) - .quality(quality)? - .build() - .process_to_webp_bytes(video_file_path) - .await -} - #[cfg(test)] mod tests { use super::*; @@ -93,7 +144,7 @@ mod tests { ]; for (input, output) in video_file_path.iter().zip(actual_webp_files.iter()) { - if let Err(e) = to_thumbnail(input, output, 128, 100.0).await { + if let Err(e) = to_thumbnail(input, output, ThumbnailSize::Scale(128), 100.0).await { eprintln!("Error: {e}; Input: {}", input.display()); panic!("{}", e); } diff --git a/crates/ffmpeg/src/model.rs b/crates/ffmpeg/src/model.rs new file mode 100644 index 000000000..751ccbaaf --- /dev/null +++ b/crates/ffmpeg/src/model.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; + +#[derive(Debug)] +pub struct FFmpegMediaData { + pub formats: Vec, + pub duration: Option, + pub start_time: Option, + pub bit_rate: i64, + pub chapters: Vec, + pub programs: Vec, + pub metadata: FFmpegMetadata, +} + +#[derive(Debug)] +pub struct FFmpegChapter { + pub id: i64, + pub start: i64, + pub end: i64, + pub time_base_den: i32, + pub time_base_num: i32, + pub metadata: FFmpegMetadata, +} + +#[derive(Default, Debug)] +pub struct FFmpegMetadata { + pub album: Option, + pub album_artist: Option, + pub artist: Option, + pub comment: Option, + pub composer: Option, + pub copyright: Option, + pub creation_time: Option>, + pub date: Option>, + pub disc: Option, + pub encoder: Option, + pub encoded_by: Option, + pub filename: Option, + pub genre: Option, + pub language: Option, + pub performer: Option, + pub publisher: Option, + pub service_name: Option, + pub service_provider: Option, + pub title: Option, + pub track: Option, + pub variant_bit_rate: Option, + pub custom: HashMap, +} + +#[derive(Debug)] +pub struct FFmpegProgram { + pub id: i32, + pub name: Option, + pub streams: Vec, + pub metadata: FFmpegMetadata, +} + +#[derive(Debug)] +pub struct FFmpegStream { + pub id: i32, + pub name: Option, + pub codec: Option, + pub aspect_ratio_num: i32, + pub aspect_ratio_den: i32, + pub frames_per_second_num: i32, + pub frames_per_second_den: i32, + pub time_base_real_den: i32, + pub time_base_real_num: i32, + pub dispositions: Vec, + pub metadata: FFmpegMetadata, +} + +#[derive(Debug)] +pub struct FFmpegCodec { + pub kind: Option, + pub sub_kind: Option, + pub tag: Option, + pub name: Option, + pub profile: Option, + pub bit_rate: i32, + pub props: Option, +} + +#[derive(Debug)] +pub enum FFmpegProps { + Video(FFmpegVideoProps), + Audio(FFmpegAudioProps), + Subtitle(FFmpegSubtitleProps), +} + +#[derive(Debug)] +pub struct FFmpegVideoProps { + pub pixel_format: Option, + pub color_range: Option, + pub bits_per_channel: Option, + pub color_space: Option, + pub color_primaries: Option, + pub color_transfer: Option, + pub field_order: Option, + pub chroma_location: Option, + pub width: i32, + pub height: i32, + pub aspect_ratio_num: Option, + pub aspect_ratio_den: Option, + pub properties: Vec, +} + +#[derive(Debug)] +pub struct FFmpegAudioProps { + pub delay: i32, + pub padding: i32, + pub sample_rate: Option, + pub sample_format: Option, + pub bit_per_sample: Option, + pub channel_layout: Option, +} + +#[derive(Debug)] +pub struct FFmpegSubtitleProps { + pub width: i32, + pub height: i32, +} diff --git a/crates/ffmpeg/src/movie_decoder.rs b/crates/ffmpeg/src/movie_decoder.rs deleted file mode 100644 index 17b527c01..000000000 --- a/crates/ffmpeg/src/movie_decoder.rs +++ /dev/null @@ -1,750 +0,0 @@ -use crate::{ - error::{Error, FfmpegError}, - utils::from_path, - video_frame::{FfmpegFrame, FrameSource, VideoFrame}, -}; - -use ffmpeg_sys_next::{ - av_buffersink_get_frame, av_buffersrc_write_frame, av_dict_get, av_display_rotation_get, - av_frame_alloc, av_frame_free, av_packet_alloc, av_packet_free, av_packet_unref, av_read_frame, - av_seek_frame, av_stream_get_side_data, avcodec_alloc_context3, avcodec_find_decoder, - avcodec_flush_buffers, avcodec_free_context, avcodec_open2, avcodec_parameters_to_context, - avcodec_receive_frame, avcodec_send_packet, avfilter_get_by_name, avfilter_graph_alloc, - avfilter_graph_config, avfilter_graph_create_filter, avfilter_graph_free, avfilter_link, - avformat_close_input, avformat_find_stream_info, avformat_open_input, AVCodec, AVCodecContext, - AVCodecID, AVFilterContext, AVFilterGraph, AVFormatContext, AVFrame, AVMediaType, AVPacket, - AVPacketSideDataType, AVRational, AVStream, AVERROR, AVERROR_EOF, AVPROBE_SCORE_MAX, - AV_DICT_IGNORE_SUFFIX, AV_TIME_BASE, EAGAIN, -}; -use std::{ - ffi::{CStr, CString}, - fmt::Write, - path::Path, - time::Duration, -}; - -#[derive(Debug, Clone, Copy)] -pub enum ThumbnailSize { - Dimensions { width: u32, height: u32 }, - Size(u32), -} - -pub struct MovieDecoder { - video_stream_index: i32, - format_context: *mut AVFormatContext, - video_codec_context: *mut AVCodecContext, - video_codec: *const AVCodec, - filter_graph: *mut AVFilterGraph, - filter_source: *mut AVFilterContext, - filter_sink: *mut AVFilterContext, - video_stream: *mut AVStream, - frame: *mut AVFrame, - packet: *mut AVPacket, - allow_seek: bool, - use_embedded_data: bool, -} - -impl MovieDecoder { - pub(crate) fn new( - filename: impl AsRef, - prefer_embedded_metadata: bool, - ) -> Result { - let filename = filename.as_ref(); - - let input_file = if filename == Path::new("-") { - Path::new("pipe:") - } else { - filename - }; - let allow_seek = filename != Path::new("-") - && !filename.starts_with("rsts://") - && !filename.starts_with("udp://"); - - let mut decoder = Self { - video_stream_index: -1, - format_context: std::ptr::null_mut(), - video_codec_context: std::ptr::null_mut(), - video_codec: std::ptr::null_mut(), - filter_graph: std::ptr::null_mut(), - filter_source: std::ptr::null_mut(), - filter_sink: std::ptr::null_mut(), - video_stream: std::ptr::null_mut(), - frame: std::ptr::null_mut(), - packet: std::ptr::null_mut(), - allow_seek, - use_embedded_data: false, - }; - - unsafe { - let input_file_cstring = from_path(input_file)?; - match avformat_open_input( - &mut decoder.format_context, - input_file_cstring.as_ptr(), - std::ptr::null_mut(), - std::ptr::null_mut(), - ) { - 0 => { - check_error( - avformat_find_stream_info(decoder.format_context, std::ptr::null_mut()), - "Failed to get stream info", - )?; - } - e => { - return Err(Error::FfmpegWithReason( - FfmpegError::from(e), - "Failed to open input".to_string(), - )) - } - } - } - - unsafe { - // This needs to remain at 100 or the app will force crash if it comes - // across a video with subtitles or any type of corruption. - if (*decoder.format_context).probe_score != AVPROBE_SCORE_MAX { - return Err(Error::CorruptVideo); - } - } - - decoder.initialize_video(prefer_embedded_metadata)?; - - decoder.frame = unsafe { av_frame_alloc() }; - if decoder.frame.is_null() { - return Err(FfmpegError::FrameAllocation.into()); - } - - Ok(decoder) - } - - pub(crate) fn decode_video_frame(&mut self) -> Result<(), Error> { - let mut frame_finished = false; - - while !frame_finished && self.get_video_packet() { - frame_finished = self.decode_video_packet()?; - } - - if !frame_finished { - return Err(Error::FrameDecodeError); - } - - Ok(()) - } - - pub(crate) const fn embedded_metadata_is_available(&self) -> bool { - self.use_embedded_data - } - - pub(crate) fn seek(&mut self, seconds: i64) -> Result<(), Error> { - if !self.allow_seek { - return Ok(()); - } - - let timestamp = i64::from(AV_TIME_BASE).checked_mul(seconds).unwrap_or(0); - - check_error( - unsafe { av_seek_frame(self.format_context, -1, timestamp, 0) }, - "Seeking video failed", - )?; - unsafe { avcodec_flush_buffers(self.video_codec_context) }; - - let mut key_frame_attempts = 0; - let mut got_frame; - - loop { - let mut count = 0; - got_frame = false; - - while !got_frame && count < 20 { - self.get_video_packet(); - got_frame = self.decode_video_packet().unwrap_or(false); - count += 1; - } - - key_frame_attempts += 1; - - if !((!got_frame || unsafe { (*self.frame).key_frame } == 0) - && key_frame_attempts < 200) - { - break; - } - } - - if !got_frame { - return Err(Error::SeekError); - } - - Ok(()) - } - - pub(crate) fn get_scaled_video_frame( - &mut self, - scaled_size: Option, - maintain_aspect_ratio: bool, - video_frame: &mut VideoFrame, - ) -> Result<(), Error> { - self.initialize_filter_graph( - unsafe { - &(*(*(*self.format_context) - .streams - .offset(self.video_stream_index as isize))) - .time_base - }, - scaled_size, - maintain_aspect_ratio, - )?; - - check_error( - unsafe { av_buffersrc_write_frame(self.filter_source, self.frame) }, - "Failed to write frame to filter graph", - )?; - - let mut new_frame = FfmpegFrame::new()?; - let mut attempts = 0; - let mut ret = unsafe { av_buffersink_get_frame(self.filter_sink, new_frame.as_mut_ptr()) }; - while ret == AVERROR(EAGAIN) && attempts < 10 { - self.decode_video_frame()?; - check_error( - unsafe { av_buffersrc_write_frame(self.filter_source, self.frame) }, - "Failed to write frame to filter graph", - )?; - ret = unsafe { av_buffersink_get_frame(self.filter_sink, new_frame.as_mut_ptr()) }; - attempts += 1; - } - if ret < 0 { - return Err(Error::FfmpegWithReason( - FfmpegError::from(ret), - "Failed to get buffer from filter".to_string(), - )); - } - - // SAFETY: these should always be positive, so clippy doesn't need to alert on them - #[allow(clippy::cast_sign_loss)] - { - video_frame.width = unsafe { (*new_frame.as_mut_ptr()).width as u32 }; - video_frame.height = unsafe { (*new_frame.as_mut_ptr()).height as u32 }; - video_frame.line_size = unsafe { (*new_frame.as_mut_ptr()).linesize[0] as u32 }; - } - video_frame.source = if self.use_embedded_data { - Some(FrameSource::Metadata) - } else { - Some(FrameSource::VideoStream) - }; - - let frame_data_size = video_frame.line_size as usize * video_frame.height as usize; - match video_frame.data.capacity() { - 0 => { - video_frame.data = Vec::with_capacity(frame_data_size); - } - c if c < frame_data_size => { - video_frame.data.reserve_exact(frame_data_size - c); - video_frame.data.clear(); - } - c if c > frame_data_size => { - video_frame.data.shrink_to(frame_data_size); - video_frame.data.clear(); - } - _ => { - video_frame.data.clear(); - } - } - - video_frame.data.extend_from_slice(unsafe { - std::slice::from_raw_parts((*new_frame.as_mut_ptr()).data[0], frame_data_size) - }); - - if !self.filter_graph.is_null() { - unsafe { avfilter_graph_free(&mut self.filter_graph) }; - self.filter_graph = std::ptr::null_mut(); - } - - Ok(()) - } - - // SAFETY: this should always be positive, so clippy doesn't need to alert on them - #[allow(clippy::cast_sign_loss)] - pub fn get_video_duration(&self) -> Duration { - Duration::from_secs(unsafe { (*self.format_context).duration as u64 / AV_TIME_BASE as u64 }) - } - - fn initialize_video(&mut self, prefer_embedded_metadata: bool) -> Result<(), Error> { - self.find_preferred_video_stream(prefer_embedded_metadata)?; - - self.video_stream = unsafe { - *(*self.format_context) - .streams - .offset(self.video_stream_index as isize) - }; - self.video_codec = - unsafe { avcodec_find_decoder((*(*self.video_stream).codecpar).codec_id) }; - if self.video_codec.is_null() { - return Err(FfmpegError::DecoderNotFound.into()); - } - - self.video_codec_context = unsafe { avcodec_alloc_context3(self.video_codec) }; - if self.video_codec_context.is_null() { - return Err(FfmpegError::VideoCodecAllocation.into()); - } - - check_error( - unsafe { - avcodec_parameters_to_context( - self.video_codec_context, - (*self.video_stream).codecpar, - ) - }, - "Failed to get parameters from context", - )?; - - unsafe { (*self.video_codec_context).workaround_bugs = 1 }; - - check_error( - unsafe { - avcodec_open2( - self.video_codec_context, - self.video_codec, - std::ptr::null_mut(), - ) - }, - "Failed to open video codec", - ) - } - - fn find_preferred_video_stream(&mut self, prefer_embedded_metadata: bool) -> Result<(), Error> { - let mut video_streams = vec![]; - let mut embedded_data_streams = vec![]; - let empty_cstring = CString::new("").unwrap(); - - for stream_idx in 0..(unsafe { (*self.format_context).nb_streams.try_into()? }) { - let stream = unsafe { *(*self.format_context).streams.offset(stream_idx as isize) }; - let codec_params = unsafe { (*stream).codecpar }; - - if unsafe { (*codec_params).codec_type } == AVMediaType::AVMEDIA_TYPE_VIDEO { - let codec_id = unsafe { (*codec_params).codec_id }; - if !prefer_embedded_metadata - || !(codec_id == AVCodecID::AV_CODEC_ID_MJPEG - || codec_id == AVCodecID::AV_CODEC_ID_PNG) - { - video_streams.push(stream_idx); - continue; - } - - if unsafe { !(*stream).metadata.is_null() } { - let mut tag = std::ptr::null_mut(); - loop { - tag = unsafe { - av_dict_get( - (*stream).metadata, - empty_cstring.as_ptr(), - tag, - AV_DICT_IGNORE_SUFFIX, - ) - }; - - if tag.is_null() { - break; - } - - // WARNING: NEVER use CString with foreign raw pointer (causes double-free) - let key = unsafe { CStr::from_ptr((*tag).key) }.to_str(); - if let Ok(key) = key { - let value = unsafe { CStr::from_ptr((*tag).value) }.to_str(); - if let Ok(value) = value { - if key == "filename" && value == "cover." { - embedded_data_streams.insert(0, stream_idx); - continue; - } - } - } - } - } - - embedded_data_streams.push(stream_idx); - } - } - - self.use_embedded_data = false; - if prefer_embedded_metadata && !embedded_data_streams.is_empty() { - self.use_embedded_data = true; - self.video_stream_index = embedded_data_streams[0]; - Ok(()) - } else if !video_streams.is_empty() { - self.video_stream_index = video_streams[0]; - Ok(()) - } else { - Err(FfmpegError::StreamNotFound.into()) - } - } - - fn get_video_packet(&mut self) -> bool { - let mut frames_available = true; - let mut frame_decoded = false; - - if !self.packet.is_null() { - unsafe { - av_packet_unref(self.packet); - av_packet_free(&mut self.packet); - } - } - - self.packet = unsafe { av_packet_alloc() }; - - while frames_available && !frame_decoded { - frames_available = unsafe { av_read_frame(self.format_context, self.packet) >= 0 }; - if frames_available { - frame_decoded = unsafe { (*self.packet).stream_index } == self.video_stream_index; - if !frame_decoded { - unsafe { av_packet_unref(self.packet) }; - } - } - } - - frame_decoded - } - - fn decode_video_packet(&self) -> Result { - if unsafe { (*self.packet).stream_index } != self.video_stream_index { - return Ok(false); - } - - let ret = unsafe { avcodec_send_packet(self.video_codec_context, self.packet) }; - if ret != AVERROR(EAGAIN) { - if ret == AVERROR_EOF { - return Ok(false); - } else if ret < 0 { - return Err(Error::FfmpegWithReason( - FfmpegError::from(ret), - "Failed to send packet to decoder".to_string(), - )); - } - } - - match unsafe { avcodec_receive_frame(self.video_codec_context, self.frame) } { - 0 => Ok(true), - e if e != AVERROR(EAGAIN) => Err(Error::FfmpegWithReason( - FfmpegError::from(e), - "Failed to receive frame from decoder".to_string(), - )), - _ => Ok(false), - } - } - - #[allow(clippy::too_many_lines)] - fn initialize_filter_graph( - &mut self, - timebase: &AVRational, - scaled_size: Option, - maintain_aspect_ratio: bool, - ) -> Result<(), Error> { - unsafe { self.filter_graph = avfilter_graph_alloc() }; - if self.filter_graph.is_null() { - return Err(FfmpegError::FilterGraphAllocation.into()); - } - - let args = unsafe { - format!( - "video_size={}x{}:pix_fmt={}:time_base={}/{}:pixel_aspect={}/{}", - (*self.video_codec_context).width, - (*self.video_codec_context).height, - (*self.video_codec_context).pix_fmt as i32, - timebase.num, - timebase.den, - (*self.video_codec_context).sample_aspect_ratio.num, - i32::max((*self.video_codec_context).sample_aspect_ratio.den, 1) - ) - }; - - setup_filter( - &mut self.filter_source, - "buffer", - "thumb_buffer", - &args, - self.filter_graph, - "Failed to create filter source", - )?; - - setup_filter_without_args( - &mut self.filter_sink, - "buffersink", - "thumb_buffersink", - self.filter_graph, - "Failed to create filter sink", - )?; - - let mut yadif_filter = std::ptr::null_mut(); - if unsafe { (*self.frame).interlaced_frame } != 0 { - setup_filter( - &mut yadif_filter, - "yadif", - "thumb_deint", - "deint=1", - self.filter_graph, - "Failed to create de-interlace filter", - )?; - } - - let mut scale_filter = std::ptr::null_mut(); - setup_filter( - &mut scale_filter, - "scale", - "thumb_scale", - &Self::create_scale_string(scaled_size, maintain_aspect_ratio), - self.filter_graph, - "Failed to create scale filter", - )?; - - let mut format_filter = std::ptr::null_mut(); - setup_filter( - &mut format_filter, - "format", - "thumb_format", - "pix_fmts=rgb24", - self.filter_graph, - "Failed to create format filter", - )?; - - let mut rotate_filter = std::ptr::null_mut(); - let rotation = self.get_stream_rotation(); - if rotation == 3 { - setup_filter( - &mut rotate_filter, - "rotate", - "thumb_rotate", - "PI", - self.filter_graph, - "Failed to create rotate filter", - )?; - } else if rotation != -1 { - setup_filter( - &mut rotate_filter, - "transpose", - "thumb_transpose", - &rotation.to_string(), - self.filter_graph, - "Failed to create transpose filter", - )?; - } - - check_error( - unsafe { - avfilter_link( - if rotate_filter.is_null() { - format_filter - } else { - rotate_filter - }, - 0, - self.filter_sink, - 0, - ) - }, - "Failed to link final filter", - )?; - - if !rotate_filter.is_null() { - check_error( - unsafe { avfilter_link(format_filter, 0, rotate_filter, 0) }, - "Failed to link format filter", - )?; - } - - check_error( - unsafe { avfilter_link(scale_filter, 0, format_filter, 0) }, - "Failed to link scale filter", - )?; - - if !yadif_filter.is_null() { - check_error( - unsafe { avfilter_link(yadif_filter, 0, scale_filter, 0) }, - "Failed to link yadif filter", - )?; - } - - check_error( - unsafe { - avfilter_link( - self.filter_source, - 0, - if yadif_filter.is_null() { - scale_filter - } else { - yadif_filter - }, - 0, - ) - }, - "Failed to link source filter", - )?; - - check_error( - unsafe { avfilter_graph_config(self.filter_graph, std::ptr::null_mut()) }, - "Failed to configure filter graph", - )?; - - Ok(()) - } - - fn create_scale_string(size: Option, maintain_aspect_ratio: bool) -> String { - let mut scaled_width; - let mut scaled_height = -1; - if size.is_none() { - return "w=0:h=0".to_string(); - } - let size = size.expect("Size should have been checked for None"); - - #[allow(clippy::cast_possible_wrap)] - match size { - ThumbnailSize::Dimensions { width, height } => { - scaled_width = width as i32; - scaled_height = height as i32; - } - ThumbnailSize::Size(width) => { - scaled_width = width as i32; - } - } - - if scaled_width <= 0 { - scaled_width = -1; - } - - if scaled_height <= 0 { - scaled_height = -1; - } - - let mut scale = String::new(); - - write!(scale, "w={scaled_width}:h={scaled_height}") - .expect("Write of const string should work"); - - if maintain_aspect_ratio { - write!(scale, ":force_original_aspect_ratio=decrease") - .expect("Write of const string should work"); - } - - // TODO: Handle anamorphic videos - - scale - } - - #[allow(clippy::cast_ptr_alignment)] - fn get_stream_rotation(&self) -> i32 { - let matrix = unsafe { - av_stream_get_side_data( - self.video_stream, - AVPacketSideDataType::AV_PKT_DATA_DISPLAYMATRIX, - std::ptr::null_mut(), - ) - } as *const i32; - - if !matrix.is_null() { - let angle = (unsafe { av_display_rotation_get(matrix) }).round(); - if angle < -135.0 { - return 3; - } else if angle > 45.0 && angle < 135.0 { - return 2; - } else if angle < -45.0 && angle > -135.0 { - return 1; - } - } - - -1 - } -} - -impl Drop for MovieDecoder { - fn drop(&mut self) { - if !self.video_codec_context.is_null() { - unsafe { - avcodec_free_context(&mut self.video_codec_context); - } - self.video_codec = std::ptr::null_mut(); - } - - if !self.format_context.is_null() { - unsafe { - avformat_close_input(&mut self.format_context); - } - self.format_context = std::ptr::null_mut(); - } - - if !self.packet.is_null() { - unsafe { - av_packet_unref(self.packet); - av_packet_free(&mut self.packet); - } - self.packet = std::ptr::null_mut(); - } - - if !self.frame.is_null() { - unsafe { - av_frame_free(&mut self.frame); - self.frame = std::ptr::null_mut(); - } - self.frame = std::ptr::null_mut(); - } - - self.video_stream_index = -1; - } -} - -fn check_error(return_code: i32, error_message: &str) -> Result<(), Error> { - if return_code < 0 { - Err(Error::FfmpegWithReason( - FfmpegError::from(return_code), - error_message.to_string(), - )) - } else { - Ok(()) - } -} - -fn setup_filter( - filter_ctx: *mut *mut AVFilterContext, - filter_name: &str, - filter_setup_name: &str, - args: &str, - graph_ctx: *mut AVFilterGraph, - error_message: &str, -) -> Result<(), Error> { - let filter_name_cstr = CString::new(filter_name).expect("CString from str"); - let filter_setup_name_cstr = CString::new(filter_setup_name).expect("CString from str"); - let args_cstr = CString::new(args).expect("CString from str"); - - check_error( - unsafe { - avfilter_graph_create_filter( - filter_ctx, - avfilter_get_by_name(filter_name_cstr.as_ptr()), - filter_setup_name_cstr.as_ptr(), - args_cstr.as_ptr(), - std::ptr::null_mut(), - graph_ctx, - ) - }, - error_message, - ) -} - -fn setup_filter_without_args( - filter_ctx: *mut *mut AVFilterContext, - filter_name: &str, - filter_setup_name: &str, - graph_ctx: *mut AVFilterGraph, - error_message: &str, -) -> Result<(), Error> { - let filter_name_cstr = CString::new(filter_name).unwrap(); - let filter_setup_name_cstr = CString::new(filter_setup_name).unwrap(); - - check_error( - unsafe { - avfilter_graph_create_filter( - filter_ctx, - avfilter_get_by_name(filter_name_cstr.as_ptr()), - filter_setup_name_cstr.as_ptr(), - std::ptr::null_mut(), - std::ptr::null_mut(), - graph_ctx, - ) - }, - error_message, - ) -} diff --git a/crates/ffmpeg/src/thumbnailer.rs b/crates/ffmpeg/src/thumbnailer.rs index 820c5a6ee..88109f2c6 100644 --- a/crates/ffmpeg/src/thumbnailer.rs +++ b/crates/ffmpeg/src/thumbnailer.rs @@ -1,6 +1,9 @@ -use crate::{film_strip_filter, Error, MovieDecoder, ThumbnailSize, VideoFrame}; +use crate::{frame_decoder::ThumbnailSize, Error, FrameDecoder}; use std::{io, ops::Deref, path::Path}; + +use image::{imageops, DynamicImage, RgbImage}; +use sd_utils::error::FileIOError; use tokio::{fs, task::spawn_blocking}; use tracing::error; use webp::Encoder; @@ -14,79 +17,114 @@ pub struct Thumbnailer { impl Thumbnailer { /// Processes an video input file and write to file system a thumbnail with webp format - pub async fn process( + pub(crate) async fn process( &self, - video_file_path: impl AsRef, - output_thumbnail_path: impl AsRef, + video_file_path: impl AsRef + Send, + output_thumbnail_path: impl AsRef + Send, ) -> Result<(), Error> { - let path = output_thumbnail_path.as_ref().parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "Cannot determine parent directory", - ) + let output_thumbnail_path = output_thumbnail_path.as_ref(); + let path = output_thumbnail_path.parent().ok_or_else(|| { + FileIOError::from(( + output_thumbnail_path, + io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot determine parent directory", + ), + )) })?; - fs::create_dir_all(path).await?; + fs::create_dir_all(path) + .await + .map_err(|e| FileIOError::from((path, e)))?; fs::write( output_thumbnail_path, &*self.process_to_webp_bytes(video_file_path).await?, ) .await - .map_err(Into::into) + .map_err(|e| FileIOError::from((output_thumbnail_path, e)).into()) } /// Processes an video input file and returns a webp encoded thumbnail as bytes - pub async fn process_to_webp_bytes( + async fn process_to_webp_bytes( &self, - video_file_path: impl AsRef, + video_file_path: impl AsRef + Send, ) -> Result, Error> { - let video_file_path = video_file_path.as_ref().to_path_buf(); let prefer_embedded_metadata = self.builder.prefer_embedded_metadata; let seek_percentage = self.builder.seek_percentage; let size = self.builder.size; let maintain_aspect_ratio = self.builder.maintain_aspect_ratio; - let with_film_strip = self.builder.with_film_strip; let quality = self.builder.quality; - spawn_blocking(move || -> Result, Error> { - let mut decoder = MovieDecoder::new(video_file_path.clone(), prefer_embedded_metadata)?; - // We actually have to decode a frame to get some metadata before we can start decoding for real - decoder.decode_video_frame()?; + spawn_blocking({ + let video_file_path = video_file_path.as_ref().to_path_buf(); + move || -> Result, Error> { + let mut decoder = FrameDecoder::new( + &video_file_path, + // TODO: allow_seek should be false for remote files + true, + prefer_embedded_metadata, + )?; - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_precision_loss)] - if !decoder.embedded_metadata_is_available() { - let result = decoder.seek( - (decoder.get_video_duration().as_secs() as f64 * f64::from(seek_percentage)) - .round() as i64, + // We actually have to decode a frame to get some metadata before we can start decoding for real + decoder.decode_video_frame()?; + + if !decoder.use_embedded() { + let result = decoder + .get_duration_secs() + .ok_or(Error::NoVideoDuration) + .and_then(|duration| { + decoder.seek( + #[allow(clippy::cast_possible_truncation)] + { + // This conversion is ok because we don't worry much about precision here + (duration * f64::from(seek_percentage)).round() as i64 + }, + ) + }); + + if let Err(err) = result { + error!( + "Failed to seek {}: {err:#?}", + video_file_path.to_string_lossy() + ); + // Seeking failed, try first frame again + // Re-instantiating decoder to avoid possible segfault + // https://github.com/dirkvdb/ffmpegthumbnailer/commit/da292ccb51a526ebc833f851a388ca308d747289 + decoder = + FrameDecoder::new(&video_file_path, false, prefer_embedded_metadata)?; + decoder.decode_video_frame()?; + } + } + + let video_frame = + decoder.get_scaled_video_frame(Some(size), maintain_aspect_ratio)?; + + let mut image = DynamicImage::ImageRgb8( + RgbImage::from_raw(video_frame.width, video_frame.height, video_frame.data) + .ok_or(Error::CorruptVideo(video_file_path.into_boxed_path()))?, ); - if let Err(err) = result { - error!("Failed to seek: {err:#?}"); - // seeking failed, try the first frame again - decoder = MovieDecoder::new(video_file_path, prefer_embedded_metadata)?; - decoder.decode_video_frame()?; - } - } + let image = if video_frame.rotation < -135.0 { + imageops::rotate180_in_place(&mut image); + image + } else if video_frame.rotation > 45.0 && video_frame.rotation < 135.0 { + image.rotate270() + } else if video_frame.rotation < -45.0 && video_frame.rotation > -135.0 { + image.rotate90() + } else { + image + }; - let mut video_frame = VideoFrame::default(); - - decoder.get_scaled_video_frame(Some(size), maintain_aspect_ratio, &mut video_frame)?; - - if with_film_strip { - film_strip_filter(&mut video_frame); - } - - // Type WebPMemory is !Send, which makes the Future in this function !Send, - // this make us `deref` to have a `&[u8]` and then `to_owned` to make a Vec - // which implies on a unwanted clone... - Ok( - Encoder::from_rgb(&video_frame.data, video_frame.width, video_frame.height) + // Type WebPMemory is !Send, which makes the Future in this function !Send, + // this make us `deref` to have a `&[u8]` and then `to_owned` to make a Vec + // which implies on a unwanted clone... + Ok(Encoder::from_image(&image) + .expect("Should not fail as the underlining DynamicImage is an RgbImage") .encode(quality) .deref() - .to_vec(), - ) + .to_vec()) + } }) .await? } @@ -102,18 +140,16 @@ pub struct ThumbnailerBuilder { seek_percentage: f32, quality: f32, prefer_embedded_metadata: bool, - with_film_strip: bool, } impl Default for ThumbnailerBuilder { fn default() -> Self { Self { maintain_aspect_ratio: true, - size: ThumbnailSize::Size(128), + size: ThumbnailSize::Scale(128), seek_percentage: 0.1, quality: 80.0, prefer_embedded_metadata: true, - with_film_strip: true, } } } @@ -125,7 +161,6 @@ impl ThumbnailerBuilder { /// - `seek_percentage`: 10% /// - `quality`: 80 /// - `prefer_embedded_metadata`: true - /// - `with_film_strip`: true pub fn new() -> Self { Self::default() } @@ -137,14 +172,8 @@ impl ThumbnailerBuilder { } /// To set a thumbnail size, respecting or not its aspect ratio, according to `maintain_aspect_ratio` value - pub const fn size(mut self, size: u32) -> Self { - self.size = ThumbnailSize::Size(size); - self - } - - /// To specify width and height of the thumbnail - pub const fn width_and_height(mut self, width: u32, height: u32) -> Self { - self.size = ThumbnailSize::Dimensions { width, height }; + pub const fn size(mut self, size: ThumbnailSize) -> Self { + self.size = size; self } @@ -173,12 +202,6 @@ impl ThumbnailerBuilder { self } - /// If `with_film_strip` is true, a film strip will be added to the thumbnail borders - pub const fn with_film_strip(mut self, with_film_strip: bool) -> Self { - self.with_film_strip = with_film_strip; - self - } - /// Builds a `Thumbnailer` struct #[must_use] pub const fn build(self) -> Thumbnailer { diff --git a/crates/ffmpeg/src/utils.rs b/crates/ffmpeg/src/utils.rs index ea202f5d8..938d003a9 100644 --- a/crates/ffmpeg/src/utils.rs +++ b/crates/ffmpeg/src/utils.rs @@ -1,4 +1,4 @@ -use crate::error::Error; +use crate::error::{Error, FFmpegError}; use std::ffi::CString; use std::path::Path; @@ -19,3 +19,14 @@ pub fn from_path(path: impl AsRef) -> Result { .ok_or(Error::PathConversion(path.to_path_buf())) } } + +pub fn check_error(return_code: i32, error_message: &str) -> Result<(), Error> { + if return_code < 0 { + Err(Error::FFmpegWithReason( + FFmpegError::from(return_code), + error_message.to_string(), + )) + } else { + Ok(()) + } +} diff --git a/crates/ffmpeg/src/video_frame.rs b/crates/ffmpeg/src/video_frame.rs index 7f775e4f7..1694dc5cf 100644 --- a/crates/ffmpeg/src/video_frame.rs +++ b/crates/ffmpeg/src/video_frame.rs @@ -1,44 +1,31 @@ -use crate::error::FfmpegError; +use crate::error::FFmpegError; use ffmpeg_sys_next::{av_frame_alloc, av_frame_free, AVFrame}; -#[derive(Debug)] -pub enum FrameSource { - VideoStream, - Metadata, -} +pub struct FFmpegFrame(*mut AVFrame); -#[derive(Debug, Default)] -pub struct VideoFrame { - pub width: u32, - pub height: u32, - pub line_size: u32, - pub data: Vec, - pub source: Option, -} - -pub struct FfmpegFrame { - data: *mut AVFrame, -} - -impl FfmpegFrame { - pub fn new() -> Result { - let data = unsafe { av_frame_alloc() }; - if data.is_null() { - return Err(FfmpegError::FrameAllocation); +impl FFmpegFrame { + pub(crate) fn new() -> Result { + let ptr = unsafe { av_frame_alloc() }; + if ptr.is_null() { + return Err(FFmpegError::FrameAllocation); } - Ok(Self { data }) + Ok(Self(ptr)) } - pub fn as_mut_ptr(&mut self) -> *mut AVFrame { - self.data + pub(crate) fn as_ref(&self) -> &AVFrame { + unsafe { self.0.as_ref() }.expect("initialized on struct creation") + } + + pub(crate) fn as_mut(&mut self) -> &mut AVFrame { + unsafe { self.0.as_mut() }.expect("initialized on struct creation") } } -impl Drop for FfmpegFrame { +impl Drop for FFmpegFrame { fn drop(&mut self) { - if !self.data.is_null() { - unsafe { av_frame_free(&mut self.data) }; - self.data = std::ptr::null_mut(); + if !self.0.is_null() { + unsafe { av_frame_free(&mut self.0) }; + self.0 = std::ptr::null_mut(); } } } diff --git a/crates/file-ext/src/extensions.rs b/crates/file-ext/src/extensions.rs index bb904a6c4..4365032bb 100644 --- a/crates/file-ext/src/extensions.rs +++ b/crates/file-ext/src/extensions.rs @@ -99,7 +99,7 @@ extension_category_enum! { // audio extensions extension_category_enum! { - AudioExtension _ALL_AUDIO_EXTENSIONS { + AudioExtension ALL_AUDIO_EXTENSIONS { Mp3 = [0x49, 0x44, 0x33], Mp2 = [0xFF, 0xFB] | [0xFF, 0xFD], M4a = [0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20] + 4, diff --git a/crates/media-metadata/Cargo.toml b/crates/media-metadata/Cargo.toml index 45e9b5a89..b9b0d5f13 100644 --- a/crates/media-metadata/Cargo.toml +++ b/crates/media-metadata/Cargo.toml @@ -1,10 +1,20 @@ [package] name = "sd-media-metadata" version = "0.0.0" -authors = ["Jake Robinson "] +authors = [ + "Jake Robinson ", + "Vítor Vasconcellos ", + "Ericson Soares ", +] edition = "2021" +[features] +ffmpeg = ["dep:sd-ffmpeg"] + [dependencies] +sd-ffmpeg = { path = "../ffmpeg", optional = true } +sd-utils = { path = "../utils" } + chrono = { workspace = true, features = ["serde"] } image = { workspace = true } rand = { workspace = true } @@ -13,6 +23,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } specta = { workspace = true, features = ["chrono"] } thiserror = { workspace = true } +tokio = { workspace = true } kamadak-exif = "0.5.5" diff --git a/crates/media-metadata/src/audio.rs b/crates/media-metadata/src/audio.rs deleted file mode 100644 index 683947424..000000000 --- a/crates/media-metadata/src/audio.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::path::Path; - -use crate::Result; - -#[derive( - Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, -)] -pub struct AudioMetadata { - duration: Option, // can't use `Duration` due to bigint - audio_codec: Option, -} - -impl AudioMetadata { - #[allow(clippy::missing_errors_doc)] - #[allow(clippy::missing_panics_doc)] - pub fn from_path(_path: impl AsRef) -> Result { - todo!() - } -} diff --git a/crates/media-metadata/src/error.rs b/crates/media-metadata/src/error.rs index 25a35d06a..5b130880a 100644 --- a/crates/media-metadata/src/error.rs +++ b/crates/media-metadata/src/error.rs @@ -1,31 +1,29 @@ -use std::{ - num::ParseFloatError, - path::{Path, PathBuf}, -}; +use sd_utils::error::FileIOError; #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("there was an i/o error {0} at {}", .1.display())] - Io(std::io::Error, Box), #[error("error from the exif crate: {0}")] Exif(#[from] exif::Error), + #[cfg(feature = "ffmpeg")] + #[error("error from the ffmpeg crate: {0}")] + FFmpeg(#[from] sd_ffmpeg::Error), + #[cfg(not(feature = "ffmpeg"))] + #[error("ffmpeg not available")] + NoFFmpeg, #[error("there was an error while parsing time with chrono: {0}")] Chrono(#[from] chrono::ParseError), #[error("there was an error while converting between types")] Conversion, #[error("there was an error while parsing the location of an image")] MediaLocationParse, - #[error("there was an error while parsing a float")] - FloatParse(#[from] ParseFloatError), - #[error("there was an error while initializing the exif reader")] - Init, - #[error("the file provided at ({0}) contains no exif data")] - NoExifDataOnPath(PathBuf), - #[error("the slice provided contains no exif data")] - NoExifDataOnSlice, #[error("serde error {0}")] Serde(#[from] serde_json::Error), + #[error("failed to join tokio task: {0}")] + TokioJoinHandle(#[from] tokio::task::JoinError), + + #[error(transparent)] + FileIO(#[from] FileIOError), } pub type Result = std::result::Result; diff --git a/crates/media-metadata/src/image/composite.rs b/crates/media-metadata/src/exif/composite.rs similarity index 100% rename from crates/media-metadata/src/image/composite.rs rename to crates/media-metadata/src/exif/composite.rs diff --git a/crates/media-metadata/src/image/consts.rs b/crates/media-metadata/src/exif/consts.rs similarity index 84% rename from crates/media-metadata/src/image/consts.rs rename to crates/media-metadata/src/exif/consts.rs index 24ad269dd..874757499 100644 --- a/crates/media-metadata/src/image/consts.rs +++ b/crates/media-metadata/src/exif/consts.rs @@ -7,10 +7,10 @@ use exif::Tag; /// ``` /// use sd_media_metadata::image::DMS_DIVISION; /// -/// let latitude = [53_f64, 19_f64, 35.11_f64]; // in DMS +/// let latitude = [53.0, 19.0, 35.11]; // in DMS /// latitude.iter().zip(DMS_DIVISION.iter()); /// ``` -pub const DMS_DIVISION: [f64; 3] = [1_f64, 60_f64, 3600_f64]; +pub const DMS_DIVISION: [f64; 3] = [1.0, 60.0, 3600.0]; /// The amount of significant figures we wish to retain after the decimal point. /// @@ -18,7 +18,7 @@ pub const DMS_DIVISION: [f64; 3] = [1_f64, 60_f64, 3600_f64]; /// applications. /// /// This is calculated with `10^n`, where `n` is the desired amount of SFs. -pub const DECIMAL_SF: f64 = 100_000_000_f64; +pub const DECIMAL_SF: f64 = 100_000_000.0; /// All possible time tags, to be zipped with [`OFFSET_TAGS`] pub const TIME_TAGS: [Tag; 3] = [Tag::DateTime, Tag::DateTimeOriginal, Tag::DateTimeDigitized]; @@ -31,19 +31,19 @@ pub const OFFSET_TAGS: [Tag; 3] = [ ]; /// The Earth's maximum latitude (can also be negative, depending on if you're North or South of the Equator). -pub const LAT_MAX_POS: f64 = 90_f64; +pub const LAT_MAX_POS: f64 = 90.0; /// The Earth's maximum longitude (can also be negative depending on if you're East or West of the Prime meridian). /// /// The negative value of this is known as the anti-meridian, and when combined they make a 360 degree circle around the Earth. -pub const LONG_MAX_POS: f64 = 180_f64; +pub const LONG_MAX_POS: f64 = 180.0; /// 125km. This is the Kármán line + a 25km additional padding just to be safe. -pub const ALT_MAX_HEIGHT: i32 = 125_000_i32; +pub const ALT_MAX_HEIGHT: i32 = 125_000; /// -1km. This should be adequate for even the Dead Sea on the Israeli border, /// the lowest point on land (and much deeper). -pub const ALT_MIN_HEIGHT: i32 = -1000_i32; +pub const ALT_MIN_HEIGHT: i32 = -1000; /// The maximum degrees that a direction can be (as a bearing, starting from 0 degrees) pub const DIRECTION_MAX: i32 = 360; diff --git a/crates/media-metadata/src/image/datetime.rs b/crates/media-metadata/src/exif/datetime.rs similarity index 97% rename from crates/media-metadata/src/image/datetime.rs rename to crates/media-metadata/src/exif/datetime.rs index 3840d4768..39c6a40b6 100644 --- a/crates/media-metadata/src/image/datetime.rs +++ b/crates/media-metadata/src/exif/datetime.rs @@ -55,10 +55,10 @@ impl MediaDate { /// /// This is for search ordering/sorting #[must_use] - pub fn unix_timestamp(&self) -> i64 { + pub const fn unix_timestamp(&self) -> i64 { match self { Self::Utc(t) => t.timestamp(), - Self::Naive(t) => t.timestamp(), + Self::Naive(t) => t.and_utc().timestamp(), } } } diff --git a/crates/media-metadata/src/image/flash/consts.rs b/crates/media-metadata/src/exif/flash/consts.rs similarity index 100% rename from crates/media-metadata/src/image/flash/consts.rs rename to crates/media-metadata/src/exif/flash/consts.rs diff --git a/crates/media-metadata/src/image/flash/data.rs b/crates/media-metadata/src/exif/flash/data.rs similarity index 98% rename from crates/media-metadata/src/image/flash/data.rs rename to crates/media-metadata/src/exif/flash/data.rs index c5adb7fa5..272b5fd7b 100644 --- a/crates/media-metadata/src/image/flash/data.rs +++ b/crates/media-metadata/src/exif/flash/data.rs @@ -1,7 +1,7 @@ use exif::Tag; use super::FlashValue; -use crate::image::{flash::consts::FLASH_MODES, ExifReader}; +use crate::exif::{flash::consts::FLASH_MODES, ExifReader}; #[derive( Default, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, specta::Type, diff --git a/crates/media-metadata/src/image/flash/mod.rs b/crates/media-metadata/src/exif/flash/mod.rs similarity index 100% rename from crates/media-metadata/src/image/flash/mod.rs rename to crates/media-metadata/src/exif/flash/mod.rs diff --git a/crates/media-metadata/src/image/flash/values.rs b/crates/media-metadata/src/exif/flash/values.rs similarity index 100% rename from crates/media-metadata/src/image/flash/values.rs rename to crates/media-metadata/src/exif/flash/values.rs diff --git a/crates/media-metadata/src/image/geographic/location.rs b/crates/media-metadata/src/exif/geographic/location.rs similarity index 99% rename from crates/media-metadata/src/image/geographic/location.rs rename to crates/media-metadata/src/exif/geographic/location.rs index 62ccf97a7..5d3d1e10a 100644 --- a/crates/media-metadata/src/image/geographic/location.rs +++ b/crates/media-metadata/src/exif/geographic/location.rs @@ -1,5 +1,5 @@ use crate::{ - image::{ + exif::{ consts::{ ALT_MAX_HEIGHT, ALT_MIN_HEIGHT, DECIMAL_SF, DIRECTION_MAX, DMS_DIVISION, LAT_MAX_POS, LONG_MAX_POS, diff --git a/crates/media-metadata/src/image/geographic/mod.rs b/crates/media-metadata/src/exif/geographic/mod.rs similarity index 100% rename from crates/media-metadata/src/image/geographic/mod.rs rename to crates/media-metadata/src/exif/geographic/mod.rs diff --git a/crates/media-metadata/src/image/geographic/pluscodes.rs b/crates/media-metadata/src/exif/geographic/pluscodes.rs similarity index 98% rename from crates/media-metadata/src/image/geographic/pluscodes.rs rename to crates/media-metadata/src/exif/geographic/pluscodes.rs index 1988b2f26..df1dff8c5 100644 --- a/crates/media-metadata/src/image/geographic/pluscodes.rs +++ b/crates/media-metadata/src/exif/geographic/pluscodes.rs @@ -1,5 +1,5 @@ use crate::{ - image::consts::{PLUSCODE_DIGITS, PLUSCODE_GRID_SIZE}, + exif::consts::{PLUSCODE_DIGITS, PLUSCODE_GRID_SIZE}, Error, }; use std::{ diff --git a/crates/media-metadata/src/exif/mod.rs b/crates/media-metadata/src/exif/mod.rs new file mode 100644 index 000000000..739c9c41e --- /dev/null +++ b/crates/media-metadata/src/exif/mod.rs @@ -0,0 +1,158 @@ +use std::path::Path; + +use exif::Tag; +use sd_utils::error::FileIOError; +use tokio::task::spawn_blocking; + +mod composite; +mod consts; +mod datetime; +mod flash; +mod geographic; +mod orientation; +mod profile; +mod reader; +mod resolution; + +pub use composite::Composite; +pub use consts::DMS_DIVISION; +pub use datetime::MediaDate; +pub use flash::{Flash, FlashMode, FlashValue}; +pub use geographic::{MediaLocation, PlusCode}; +pub use orientation::Orientation; +pub use profile::ColorProfile; +pub use reader::ExifReader; +pub use resolution::Resolution; + +use crate::{Error, Result}; + +#[derive(Default, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct ExifMetadata { + pub resolution: Resolution, + pub date_taken: Option, + pub location: Option, + pub camera_data: CameraData, + pub artist: Option, + pub description: Option, + pub copyright: Option, + pub exif_version: Option, +} + +impl ExifMetadata { + pub async fn from_path(path: impl AsRef + Send) -> Result> { + match spawn_blocking({ + let path = path.as_ref().to_owned(); + move || ExifReader::from_path(path).map(|reader| Self::from_reader(&reader)) + }) + .await? + { + Ok(data) => Ok(Some(data)), + Err(Error::Exif( + exif::Error::NotFound(_) + | exif::Error::NotSupported(_) + | exif::Error::BlankValue(_), + )) => Ok(None), + Err(Error::Exif(exif::Error::Io(e))) => Err(FileIOError::from((path, e)).into()), + Err(e) => Err(e), + } + } + + pub fn from_slice(bytes: &[u8]) -> Result> { + let res = ExifReader::from_slice(bytes).map(|reader| Self::from_reader(&reader)); + + if matches!( + res, + Err(Error::Exif( + exif::Error::NotFound(_) + | exif::Error::NotSupported(_) + | exif::Error::BlankValue(_) + )) + ) { + return Ok(None); + } + + res.map(Some) + } + + #[allow(clippy::field_reassign_with_default)] + fn from_reader(reader: &ExifReader) -> Self { + Self { + resolution: Resolution::from_reader(reader), + date_taken: MediaDate::from_reader(reader), + location: MediaLocation::from_exif_reader(reader).ok(), + camera_data: CameraData { + device_make: reader.get_tag(Tag::Make), + device_model: reader.get_tag(Tag::Model), + color_space: reader.get_tag(Tag::ColorSpace), + color_profile: ColorProfile::from_reader(reader), + focal_length: reader.get_tag(Tag::FocalLength), + shutter_speed: reader.get_tag(Tag::ShutterSpeedValue), + flash: Flash::from_reader(reader), + orientation: Orientation::from_reader(reader).unwrap_or_default(), + lens_make: reader.get_tag(Tag::LensMake), + lens_model: reader.get_tag(Tag::LensModel), + bit_depth: reader.get_tag::(Tag::BitsPerSample).map_or_else( + || { + reader + .get_tag::(Tag::CompressedBitsPerPixel) + .unwrap_or_default() + .parse() + .ok() + }, + |x| x.parse::().ok(), + ), + zoom: reader + .get_tag(Tag::DigitalZoomRatio) + .map(|x: String| x.replace("unused", "1").parse().ok()) + .unwrap_or_default(), + iso: reader.get_tag(Tag::PhotographicSensitivity), + software: reader.get_tag(Tag::Software), + serial_number: reader.get_tag(Tag::BodySerialNumber), + lens_serial_number: reader.get_tag(Tag::LensSerialNumber), + contrast: reader.get_tag(Tag::Contrast), + saturation: reader.get_tag(Tag::Saturation), + sharpness: reader.get_tag(Tag::Sharpness), + composite: Composite::from_reader(reader), + }, + artist: reader.get_tag(Tag::Artist), + description: reader.get_tag(Tag::ImageDescription), + copyright: reader.get_tag(Tag::Copyright), + exif_version: reader.get_tag(Tag::ExifVersion), + } + } +} + +#[derive(Default, Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct CameraData { + pub device_make: Option, + pub device_model: Option, + pub color_space: Option, + pub color_profile: Option, + pub focal_length: Option, + pub shutter_speed: Option, + pub flash: Option, + pub orientation: Orientation, + pub lens_make: Option, + pub lens_model: Option, + pub bit_depth: Option, + pub zoom: Option, + pub iso: Option, + pub software: Option, + pub serial_number: Option, + pub lens_serial_number: Option, + pub contrast: Option, + pub saturation: Option, + pub sharpness: Option, + pub composite: Option, +} + +// TODO(brxken128): more exif spec reading so we can source color spaces correctly too +// pub enum ImageColorSpace { +// Rgb, +// RgbP, +// SRgb, +// Cmyk, +// DciP3, +// Wiz, +// Biz, +// } diff --git a/crates/media-metadata/src/image/orientation.rs b/crates/media-metadata/src/exif/orientation.rs similarity index 100% rename from crates/media-metadata/src/image/orientation.rs rename to crates/media-metadata/src/exif/orientation.rs diff --git a/crates/media-metadata/src/image/profile.rs b/crates/media-metadata/src/exif/profile.rs similarity index 100% rename from crates/media-metadata/src/image/profile.rs rename to crates/media-metadata/src/exif/profile.rs diff --git a/crates/media-metadata/src/image/reader.rs b/crates/media-metadata/src/exif/reader.rs similarity index 76% rename from crates/media-metadata/src/image/reader.rs rename to crates/media-metadata/src/exif/reader.rs index 720ffdefd..98a13e5ea 100644 --- a/crates/media-metadata/src/image/reader.rs +++ b/crates/media-metadata/src/exif/reader.rs @@ -1,3 +1,5 @@ +use crate::Result; + use std::{ fs::File, io::{BufReader, Cursor}, @@ -6,8 +8,7 @@ use std::{ }; use exif::{Exif, In, Tag}; - -use crate::{Error, Result}; +use sd_utils::error::FileIOError; /// An [`ExifReader`]. This can get exif tags from images (either files or slices). pub struct ExifReader(Exif); @@ -16,19 +17,17 @@ impl ExifReader { pub fn from_path(path: impl AsRef) -> Result { exif::Reader::new() .read_from_container(&mut BufReader::new( - File::open(&path) - .map_err(|e| Error::Io(e, path.as_ref().to_path_buf().into_boxed_path()))?, + File::open(&path).map_err(|e| FileIOError::from((path, e)))?, )) - .map_or_else( - |_| Err(Error::NoExifDataOnPath(path.as_ref().to_path_buf())), - |reader| Ok(Self(reader)), - ) + .map(Self) + .map_err(Into::into) } pub fn from_slice(slice: &[u8]) -> Result { exif::Reader::new() .read_from_container(&mut Cursor::new(slice)) - .map_or_else(|_| Err(Error::NoExifDataOnSlice), |reader| Ok(Self(reader))) + .map(Self) + .map_err(Into::into) } /// A helper function which gets the target `Tag` as `T`, provided `T` impls `FromStr`. diff --git a/crates/media-metadata/src/image/resolution.rs b/crates/media-metadata/src/exif/resolution.rs similarity index 100% rename from crates/media-metadata/src/image/resolution.rs rename to crates/media-metadata/src/exif/resolution.rs diff --git a/crates/media-metadata/src/ffmpeg/audio_props.rs b/crates/media-metadata/src/ffmpeg/audio_props.rs new file mode 100644 index 000000000..2b71a0db9 --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/audio_props.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct AudioProps { + pub delay: i32, + pub padding: i32, + pub sample_rate: Option, + pub sample_format: Option, + pub bit_per_sample: Option, + pub channel_layout: Option, +} diff --git a/crates/media-metadata/src/ffmpeg/chapter.rs b/crates/media-metadata/src/ffmpeg/chapter.rs new file mode 100644 index 000000000..7944213bf --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/chapter.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::metadata::Metadata; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct Chapter { + pub id: i32, + pub start: (u32, u32), + pub end: (u32, u32), + pub time_base_den: i32, + pub time_base_num: i32, + pub metadata: Metadata, +} diff --git a/crates/media-metadata/src/ffmpeg/codec.rs b/crates/media-metadata/src/ffmpeg/codec.rs new file mode 100644 index 000000000..c583ed59f --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/codec.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::{audio_props::AudioProps, subtitle_props::SubtitleProps, video_props::VideoProps}; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct Codec { + pub kind: Option, + pub sub_kind: Option, + pub tag: Option, + pub name: Option, + pub profile: Option, + pub bit_rate: i32, + pub props: Option, +} + +#[derive(Debug, Serialize, Deserialize, Type)] +pub enum Props { + Video(VideoProps), + Audio(AudioProps), + Subtitle(SubtitleProps), +} diff --git a/crates/media-metadata/src/ffmpeg/metadata.rs b/crates/media-metadata/src/ffmpeg/metadata.rs new file mode 100644 index 000000000..8b576dff7 --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/metadata.rs @@ -0,0 +1,31 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Default, Debug, Serialize, Deserialize, Type)] +pub struct Metadata { + pub album: Option, + pub album_artist: Option, + pub artist: Option, + pub comment: Option, + pub composer: Option, + pub copyright: Option, + pub creation_time: Option>, + pub date: Option>, + pub disc: Option, + pub encoder: Option, + pub encoded_by: Option, + pub filename: Option, + pub genre: Option, + pub language: Option, + pub performer: Option, + pub publisher: Option, + pub service_name: Option, + pub service_provider: Option, + pub title: Option, + pub track: Option, + pub variant_bit_rate: Option, + pub custom: HashMap, +} diff --git a/crates/media-metadata/src/ffmpeg/mod.rs b/crates/media-metadata/src/ffmpeg/mod.rs new file mode 100644 index 000000000..074dd42f6 --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/mod.rs @@ -0,0 +1,342 @@ +use crate::Result; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +pub mod audio_props; +pub mod chapter; +pub mod codec; +pub mod metadata; +pub mod program; +pub mod stream; +pub mod subtitle_props; +pub mod video_props; + +use chapter::Chapter; +use metadata::Metadata; +use program::Program; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct FFmpegMetadata { + pub formats: Vec, + pub duration: Option<(u32, u32)>, + pub start_time: Option<(u32, u32)>, + pub bit_rate: (u32, u32), + pub chapters: Vec, + pub programs: Vec, + pub metadata: Metadata, +} + +impl FFmpegMetadata { + pub async fn from_path(path: impl AsRef + Send) -> Result { + #[cfg(not(feature = "ffmpeg"))] + { + let _ = path; + Err(crate::Error::NoFFmpeg) + } + + #[cfg(feature = "ffmpeg")] + { + sd_ffmpeg::probe(path) + .await + .map(Into::into) + .map_err(Into::into) + } + } +} + +#[cfg(feature = "ffmpeg")] +mod extract_data { + + use sd_ffmpeg::model::{ + FFmpegAudioProps, FFmpegChapter, FFmpegCodec, FFmpegMediaData, FFmpegMetadata, + FFmpegProgram, FFmpegProps, FFmpegStream, FFmpegSubtitleProps, FFmpegVideoProps, + }; + + impl From for super::FFmpegMetadata { + fn from( + FFmpegMediaData { + formats, + duration, + start_time, + bit_rate, + chapters, + programs, + metadata, + }: FFmpegMediaData, + ) -> Self { + Self { + formats, + duration: duration.map(|duration| { + #[allow(clippy::cast_possible_truncation)] + { + // SAFETY: We're splitting in (high, low) parts, so we're not going to lose data on truncation + ((duration >> 32) as u32, duration as u32) + } + }), + start_time: start_time.map(|start_time| { + #[allow(clippy::cast_possible_truncation)] + { + // SAFETY: We're splitting in (high, low) parts, so we're not going to lose data on truncation + ((start_time >> 32) as u32, start_time as u32) + } + }), + bit_rate: { + #[allow(clippy::cast_possible_truncation)] + { + // SAFETY: We're splitting in (high, low) parts, so we're not going to lose data on truncation + ((bit_rate >> 32) as u32, bit_rate as u32) + } + }, + chapters: chapters.into_iter().map(Into::into).collect(), + programs: programs.into_iter().map(Into::into).collect(), + metadata: metadata.into(), + } + } + } + + impl From for super::Chapter { + fn from( + FFmpegChapter { + id, + start, + end, + time_base_den, + time_base_num, + metadata, + }: FFmpegChapter, + ) -> Self { + Self { + id: { + #[allow(clippy::cast_possible_truncation)] + { + // NOTICE: chapter.id is a i64, but I think it will be extremely rare to have a chapter id that doesn't fit in a i32 + id as i32 + } + }, + // TODO: FIX these 2 when rspc/specta supports bigint + start: { + #[allow(clippy::cast_possible_truncation)] + { + // SAFETY: We're splitting in (high, low) parts, so we're not going to lose data on truncation + ((start >> 32) as u32, start as u32) + } + }, + end: { + #[allow(clippy::cast_possible_truncation)] + { + // SAFETY: We're splitting in (high, low) parts, so we're not going to lose data on truncation + ((end >> 32) as u32, end as u32) + } + }, + time_base_num, + time_base_den, + metadata: metadata.into(), + } + } + } + + impl From for super::Program { + fn from( + FFmpegProgram { + id, + name, + streams, + metadata, + }: FFmpegProgram, + ) -> Self { + Self { + id, + name, + streams: streams.into_iter().map(Into::into).collect(), + metadata: metadata.into(), + } + } + } + + impl From for super::stream::Stream { + fn from( + FFmpegStream { + id, + name, + codec, + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + dispositions, + metadata, + }: FFmpegStream, + ) -> Self { + Self { + id, + name, + codec: codec.map(Into::into), + aspect_ratio_num, + aspect_ratio_den, + frames_per_second_num, + frames_per_second_den, + time_base_real_den, + time_base_real_num, + dispositions, + metadata: metadata.into(), + } + } + } + + impl From for super::codec::Codec { + fn from( + FFmpegCodec { + kind, + sub_kind, + tag, + name, + profile, + bit_rate, + props, + }: FFmpegCodec, + ) -> Self { + Self { + kind, + sub_kind, + tag, + name, + profile, + bit_rate, + props: props.map(Into::into), + } + } + } + + impl From for super::codec::Props { + fn from(props: FFmpegProps) -> Self { + match props { + FFmpegProps::Video(video_props) => Self::Video(video_props.into()), + FFmpegProps::Audio(audio_props) => Self::Audio(audio_props.into()), + FFmpegProps::Subtitle(subtitle_props) => Self::Subtitle(subtitle_props.into()), + } + } + } + + impl From for super::audio_props::AudioProps { + fn from( + FFmpegAudioProps { + delay, + padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + }: FFmpegAudioProps, + ) -> Self { + Self { + delay, + padding, + sample_rate, + sample_format, + bit_per_sample, + channel_layout, + } + } + } + + impl From for super::subtitle_props::SubtitleProps { + fn from(FFmpegSubtitleProps { width, height }: FFmpegSubtitleProps) -> Self { + Self { width, height } + } + } + + impl From for super::video_props::VideoProps { + fn from( + FFmpegVideoProps { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties, + }: FFmpegVideoProps, + ) -> Self { + Self { + pixel_format, + color_range, + bits_per_channel, + color_space, + color_primaries, + color_transfer, + field_order, + chroma_location, + width, + height, + aspect_ratio_num, + aspect_ratio_den, + properties, + } + } + } + + impl From for super::Metadata { + fn from( + FFmpegMetadata { + album, + album_artist, + artist, + comment, + composer, + copyright, + creation_time, + date, + disc, + encoder, + encoded_by, + filename, + genre, + language, + performer, + publisher, + service_name, + service_provider, + title, + track, + variant_bit_rate, + custom, + }: FFmpegMetadata, + ) -> Self { + Self { + album, + album_artist, + artist, + comment, + composer, + copyright, + creation_time, + date, + disc, + encoder, + encoded_by, + filename, + genre, + language, + performer, + publisher, + service_name, + service_provider, + title, + track, + variant_bit_rate, + custom, + } + } + } +} diff --git a/crates/media-metadata/src/ffmpeg/program.rs b/crates/media-metadata/src/ffmpeg/program.rs new file mode 100644 index 000000000..54952e126 --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/program.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::{metadata::Metadata, stream::Stream}; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct Program { + pub id: i32, + pub name: Option, + pub streams: Vec, + pub metadata: Metadata, +} diff --git a/crates/media-metadata/src/ffmpeg/stream.rs b/crates/media-metadata/src/ffmpeg/stream.rs new file mode 100644 index 000000000..a5a299752 --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/stream.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use super::{codec::Codec, metadata::Metadata}; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct Stream { + pub id: i32, + pub name: Option, + pub codec: Option, + pub aspect_ratio_num: i32, + pub aspect_ratio_den: i32, + pub frames_per_second_num: i32, + pub frames_per_second_den: i32, + pub time_base_real_den: i32, + pub time_base_real_num: i32, + pub dispositions: Vec, + pub metadata: Metadata, +} diff --git a/crates/media-metadata/src/ffmpeg/subtitle_props.rs b/crates/media-metadata/src/ffmpeg/subtitle_props.rs new file mode 100644 index 000000000..f84bcdbda --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/subtitle_props.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct SubtitleProps { + pub width: i32, + pub height: i32, +} diff --git a/crates/media-metadata/src/ffmpeg/video_props.rs b/crates/media-metadata/src/ffmpeg/video_props.rs new file mode 100644 index 000000000..7447f00eb --- /dev/null +++ b/crates/media-metadata/src/ffmpeg/video_props.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Serialize, Deserialize, Type)] +pub struct VideoProps { + pub pixel_format: Option, + pub color_range: Option, + pub bits_per_channel: Option, + pub color_space: Option, + pub color_primaries: Option, + pub color_transfer: Option, + pub field_order: Option, + pub chroma_location: Option, + pub width: i32, + pub height: i32, + pub aspect_ratio_num: Option, + pub aspect_ratio_den: Option, + pub properties: Vec, +} diff --git a/crates/media-metadata/src/image/mod.rs b/crates/media-metadata/src/image/mod.rs deleted file mode 100644 index 104bfee07..000000000 --- a/crates/media-metadata/src/image/mod.rs +++ /dev/null @@ -1,135 +0,0 @@ -use exif::Tag; -use std::path::Path; - -mod composite; -mod consts; -mod datetime; -mod flash; -mod geographic; -mod orientation; -mod profile; -mod reader; -mod resolution; - -pub use composite::Composite; -pub use consts::DMS_DIVISION; -pub use datetime::MediaDate; -pub use flash::{Flash, FlashMode, FlashValue}; -pub use geographic::{MediaLocation, PlusCode}; -pub use orientation::Orientation; -pub use profile::ColorProfile; -pub use reader::ExifReader; -pub use resolution::Resolution; - -use crate::Result; - -#[derive(Default, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)] -pub struct ImageMetadata { - pub resolution: Resolution, - pub date_taken: Option, - pub location: Option, - pub camera_data: CameraData, - pub artist: Option, - pub description: Option, - pub copyright: Option, - pub exif_version: Option, -} - -#[derive(Default, Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] -pub struct CameraData { - pub device_make: Option, - pub device_model: Option, - pub color_space: Option, - pub color_profile: Option, - pub focal_length: Option, - pub shutter_speed: Option, - pub flash: Option, - pub orientation: Orientation, - pub lens_make: Option, - pub lens_model: Option, - pub bit_depth: Option, - pub red_eye: Option, - pub zoom: Option, - pub iso: Option, - pub software: Option, - pub serial_number: Option, - pub lens_serial_number: Option, - pub contrast: Option, - pub saturation: Option, - pub sharpness: Option, - pub composite: Option, -} - -impl ImageMetadata { - pub fn from_path(path: impl AsRef) -> Result { - Self::from_reader(&ExifReader::from_path(path)?) - } - - pub fn from_slice(bytes: &[u8]) -> Result { - Self::from_reader(&ExifReader::from_slice(bytes)?) - } - - #[allow(clippy::field_reassign_with_default)] - pub fn from_reader(reader: &ExifReader) -> Result { - let mut data = Self::default(); - let camera_data = &mut data.camera_data; - - data.date_taken = MediaDate::from_reader(reader); - data.resolution = Resolution::from_reader(reader); - data.artist = reader.get_tag(Tag::Artist); - data.description = reader.get_tag(Tag::ImageDescription); - data.copyright = reader.get_tag(Tag::Copyright); - data.exif_version = reader.get_tag(Tag::ExifVersion); - data.location = MediaLocation::from_exif_reader(reader).ok(); - - camera_data.device_make = reader.get_tag(Tag::Make); - camera_data.device_model = reader.get_tag(Tag::Model); - camera_data.focal_length = reader.get_tag(Tag::FocalLength); - camera_data.shutter_speed = reader.get_tag(Tag::ShutterSpeedValue); - camera_data.color_space = reader.get_tag(Tag::ColorSpace); - camera_data.color_profile = ColorProfile::from_reader(reader); - - camera_data.lens_make = reader.get_tag(Tag::LensMake); - camera_data.lens_model = reader.get_tag(Tag::LensModel); - camera_data.iso = reader.get_tag(Tag::PhotographicSensitivity); - camera_data.zoom = reader - .get_tag(Tag::DigitalZoomRatio) - .map(|x: String| x.replace("unused", "1").parse().ok()) - .unwrap_or_default(); - - camera_data.bit_depth = reader.get_tag::(Tag::BitsPerSample).map_or_else( - || { - reader - .get_tag::(Tag::CompressedBitsPerPixel) - .unwrap_or_default() - .parse() - .ok() - }, - |x| x.parse::().ok(), - ); - - camera_data.orientation = Orientation::from_reader(reader).unwrap_or_default(); - camera_data.flash = Flash::from_reader(reader); - camera_data.software = reader.get_tag(Tag::Software); - camera_data.serial_number = reader.get_tag(Tag::BodySerialNumber); - camera_data.lens_serial_number = reader.get_tag(Tag::LensSerialNumber); - camera_data.software = reader.get_tag(Tag::Software); - camera_data.contrast = reader.get_tag(Tag::Contrast); - camera_data.saturation = reader.get_tag(Tag::Saturation); - camera_data.sharpness = reader.get_tag(Tag::Sharpness); - camera_data.composite = Composite::from_reader(reader); - - Ok(data) - } -} - -// TODO(brxken128): more exif spec reading so we can source color spaces correctly too -// pub enum ImageColorSpace { -// Rgb, -// RgbP, -// SRgb, -// Cmyk, -// DciP3, -// Wiz, -// Biz, -// } diff --git a/crates/media-metadata/src/lib.rs b/crates/media-metadata/src/lib.rs index 2e9759cf2..026ab7c81 100644 --- a/crates/media-metadata/src/lib.rs +++ b/crates/media-metadata/src/lib.rs @@ -11,30 +11,28 @@ clippy::unwrap_used, unused_qualifications, rust_2018_idioms, - clippy::expect_used, trivial_casts, trivial_numeric_casts, unused_allocation, - clippy::as_conversions, - clippy::dbg_macro + clippy::unnecessary_cast, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::dbg_macro, + clippy::deprecated_cfg_attr, + clippy::separated_literal_suffix, + deprecated )] +#![forbid(deprecated_in_future)] #![forbid(unsafe_code)] #![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)] -pub mod audio; mod error; -pub mod image; -pub mod video; +pub mod exif; +pub mod ffmpeg; -pub use audio::AudioMetadata; pub use error::{Error, Result}; -pub use image::ImageMetadata; -pub use video::VideoMetadata; - -#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] -#[serde(tag = "type")] -pub enum MediaMetadata { - Image(Box), - Video(Box), - Audio(Box), -} +pub use exif::ExifMetadata; +pub use ffmpeg::FFmpegMetadata; diff --git a/crates/media-metadata/src/video.rs b/crates/media-metadata/src/video.rs deleted file mode 100644 index a1c4922dd..000000000 --- a/crates/media-metadata/src/video.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::path::Path; - -use crate::Result; - -#[derive( - Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, -)] -pub struct VideoMetadata { - duration: Option, // bigint - video_codec: Option, - audio_codec: Option, -} - -impl VideoMetadata { - #[allow(clippy::missing_errors_doc)] - #[allow(clippy::missing_panics_doc)] - pub fn from_path(_path: impl AsRef) -> Result { - todo!() - } -} diff --git a/crates/prisma/src/lib.rs b/crates/prisma/src/lib.rs index 1a162008a..d5116b855 100644 --- a/crates/prisma/src/lib.rs +++ b/crates/prisma/src/lib.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "256"] #[allow(warnings, unused)] pub mod prisma; #[allow(warnings, unused)] diff --git a/crates/task-system/Cargo.toml b/crates/task-system/Cargo.toml index 062983eac..bb488e194 100644 --- a/crates/task-system/Cargo.toml +++ b/crates/task-system/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "sd-task-system" version = "0.1.0" -authors = ["Ericson \"Fogo\" Soares "] -rust-version = "1.75.0" +authors = ["Ericson Soares "] +rust-version = "1.75" license.workspace = true edition.workspace = true repository.workspace = true @@ -25,20 +25,17 @@ tokio = { workspace = true, features = [ tokio-stream = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } - # External deps downcast-rs = "1.2.0" pin-project = "1.1.4" [dev-dependencies] -tokio = { workspace = true, features = ["macros", "test-util", "fs"] } -tempfile = { workspace = true } -rand = "0.8.5" -tracing-test = { workspace.dev-dependencies = true, features = [ - "no-env-filter", -] } -thiserror = { workspace = true } lending-stream = { workspace = true } -serde = { workspace = true, features = ["derive"] } +rand = { workspace = true } rmp-serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "test-util", "fs"] } +tracing-test = { workspace = true, features = ["no-env-filter"] } uuid = { workspace = true, features = ["serde"] } diff --git a/crates/utils/src/db.rs b/crates/utils/src/db.rs index e02df080d..67e2d6e05 100644 --- a/crates/utils/src/db.rs +++ b/crates/utils/src/db.rs @@ -63,6 +63,16 @@ pub fn inode_to_db(inode: u64) -> Vec { inode.to_le_bytes().to_vec() } +pub fn ffmpeg_data_field_to_db(field: i64) -> Vec { + field.to_be_bytes().to_vec() +} + +pub fn ffmpeg_data_field_from_db(field: &[u8]) -> i64 { + i64::from_be_bytes([ + field[0], field[1], field[2], field[3], field[4], field[5], field[6], field[7], + ]) +} + pub fn size_in_bytes_from_db(db_size_in_bytes: &[u8]) -> u64 { u64::from_be_bytes([ db_size_in_bytes[0], diff --git a/interface/app/$libraryId/Explorer/Inspector/MediaData.tsx b/interface/app/$libraryId/Explorer/Inspector/MediaData.tsx index 6aaddf84a..7b2ade2db 100644 --- a/interface/app/$libraryId/Explorer/Inspector/MediaData.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/MediaData.tsx @@ -1,13 +1,13 @@ import dayjs from 'dayjs'; -import customParseFormat from 'dayjs/plugin/customParseFormat'; // import plugin - -import utc from 'dayjs/plugin/utc'; // import plugin - import { + capitalize, CoordinatesFormat, - MediaDate, + ExifMetadata, + FFmpegMetadata, + humanizeSize, + int32ArrayToBigInt, MediaLocation, - MediaMetadata, + MediaData as RemoteMediaData, useSelector, useUnitFormatStore } from '@sd/client'; @@ -18,38 +18,6 @@ import { Platform, usePlatform } from '~/util/Platform'; import { explorerStore } from '../store'; import { MetaData } from './index'; -interface Props { - data: MediaMetadata; -} - -// const DateFormatWithTz = 'YYYY-MM-DD HH:mm:ss ZZ'; -// const DateFormatWithoutTz = 'YYYY-MM-DD HH:mm:ss'; - -// const formatMediaDate = (datetime: MediaDate): { formatted: string; raw: string } | undefined => { -// dayjs.extend(customParseFormat); -// dayjs.extend(utc); - -// // dayjs.tz.setDefault(dayjs.tz.guess()); - -// const getTzData = (dt: string): [string, number] => { -// if (dt.includes('+')) -// return [DateFormatWithTz, Number.parseInt(dt.substring(dt.indexOf('+'), 3))]; -// return [DateFormatWithoutTz, 0]; -// }; - -// const [tzFormat, tzOffset] = getTzData(datetime); - -// console.log({ -// formatted: dayjs(datetime, tzFormat).utcOffset(tzOffset).format('HH:mm:ss, MMM Do YYYY'), -// raw: datetime -// }); - -// return { -// formatted: dayjs(datetime, tzFormat).utcOffset(tzOffset).format('HH:mm:ss, MMM Do YYYY'), -// raw: datetime -// }; -// }; - const formatLocationDD = (loc: MediaLocation, dp?: number): string => { // the lack of a + here will mean that coordinates may have padding at the end // google does the same (if one is larger than the other, the smaller one will be padded with zeroes) @@ -99,24 +67,154 @@ const UrlMetadataValue = (props: { text: string; url: string; platform: Platform ); -// const orientations = { -// Normal: 'Normal', -// MirroredHorizontal: 'Horizontally mirrored', -// MirroredHorizontalAnd90CW: 'Mirrored horizontally and rotated 90° clockwise', -// MirroredHorizontalAnd270CW: 'Mirrored horizontally and rotated 270° clockwise', -// MirroredVertical: 'Vertically mirrored', -// CW90: 'Rotated 90° clockwise', -// CW180: 'Rotated 180° clockwise', -// CW270: 'Rotated 270° clockwise' -// }; - -const MediaData = ({ data }: Props) => { +const ExifMediaData = (data: ExifMetadata) => { const platform = usePlatform(); const { t } = useLocale(); const coordinatesFormat = useUnitFormatStore().coordinatesFormat; const showMoreInfo = useSelector(explorerStore, (s) => s.showMoreInfo); - return data.type === 'Image' ? ( + return ( + <> + + + + ) + } + /> + + ) + } + /> + + + + + + + + + + + ); +}; + +const FFmpegMediaData = (data: FFmpegMetadata) => { + const { t } = useLocale(); + + const streamKinds = new Set( + data.programs.flatMap((program) => program.streams.map((stream) => stream.codec?.kind)) + ); + const type = streamKinds.has('video') + ? 'Video' + : streamKinds.has('audio') + ? 'Audio' + : capitalize(streamKinds.values().next().value) ?? 'Unknown'; + + const bit_rate = humanizeSize(int32ArrayToBigInt(data.bit_rate), { + is_bit: true, + base_unit: 'binary', + use_plural: false + }); + + const duration_ms = data.duration ? int32ArrayToBigInt(data.duration) / 1000n : null; + const duration = duration_ms + ? dayjs.duration( + Number(duration_ms / 1000n) + Number(duration_ms % 1000n) / 1000, + 'seconds' + ) + : null; + + const start_time_ms = data.start_time ? int32ArrayToBigInt(data.start_time) / 1000n : null; + const start_time = start_time_ms + ? dayjs.duration( + Number(start_time_ms / 1000n) + Number(start_time_ms % 1000n) / 1000, + 'seconds' + ) + : null; + + const chapters = data.chapters + .map((chapter) => { + const num = BigInt(chapter.time_base_num); + const den = BigInt(chapter.time_base_den); + + const start = dayjs.duration( + Number((int32ArrayToBigInt(chapter.start) * num) / den), + 'seconds' + ); + + const end = dayjs.duration( + Number((int32ArrayToBigInt(chapter.end) * num) / den), + 'seconds' + ); + + return `${start.format('HH:mm:ss')} - ${end.format('HH:mm:ss')}`; + }) + .join('\n'); + + return ( + <> + + + {duration && } + {start_time && ( + + )} + {chapters && } + + ); +}; + +interface Props { + data: RemoteMediaData; +} + +export const MediaData = ({ data }: Props) => { + const { t } = useLocale(); + const showMoreInfo = useSelector(explorerStore, (s) => s.showMoreInfo); + + return (
{ variant="apple" title={t('more_info')} > - - - - ) - } - /> - - ) - } - /> - - - - - - - - - + {'Exif' in data ? ExifMediaData(data.Exif) : FFmpegMediaData(data.FFmpeg)}
- ) : null; + ); }; export default MediaData; diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index fdba308b9..5e3cf41dd 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -27,11 +27,11 @@ import { useLocation } from 'react-router'; import { Link as NavLink } from 'react-router-dom'; import Sticky from 'react-sticky-el'; import { - byteSize, FilePath, FilePathWithObject, getExplorerItemData, getItemFilePath, + humanizeSize, NonIndexedPathItem, Object, ObjectKindEnum, @@ -235,21 +235,21 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { }); const filesMediaData = useLibraryQuery(['files.getMediaData', objectData?.id ?? -1], { - enabled: objectData?.kind === ObjectKindEnum.Image && readyToFetch + enabled: objectData != null && readyToFetch }); const ephemeralLocationMediaData = useBridgeQuery( ['ephemeralFiles.getMediaData', ephemeralPathData != null ? ephemeralPathData.path : ''], { - enabled: ephemeralPathData?.kind === ObjectKindEnum.Image && readyToFetch + enabled: ephemeralPathData != null && readyToFetch } ); - const mediaData = filesMediaData ?? ephemeralLocationMediaData ?? null; + const mediaData = filesMediaData.data ?? ephemeralLocationMediaData.data ?? null; const fullPath = queriedFullPath.data ?? ephemeralPathData?.path; - const { name, isDir, kind, size, casId, dateCreated, dateAccessed, dateModified, dateIndexed } = + const { isDir, kind, size, casId, dateCreated, dateAccessed, dateModified, dateIndexed } = useExplorerItemData(item); const pubId = objectData != null ? uniqueId(objectData) : null; @@ -365,7 +365,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { )} - {mediaData.data && } + {mediaData && } {isDir ? t('folder') : translateKindName(kind)} @@ -483,7 +483,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { getExplorerItemData(item); if (item.type !== 'NonIndexedPath' || !item.item.is_dir) { - metadata.size = (metadata.size ?? BigInt(0)) + size.original; + metadata.size = (metadata.size ?? BigInt(0)) + BigInt(size.original); } if (dateCreated) @@ -529,7 +529,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { { return ( -
+
{Icon && } - {label} + + {label} + {value ?? '--'} diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx index 19963a72a..6b513e611 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx @@ -1,8 +1,8 @@ import clsx from 'clsx'; import { memo, useMemo } from 'react'; import { - byteSize, getItemFilePath, + humanizeSize, useLibraryQuery, useSelector, type ExplorerItem @@ -142,7 +142,7 @@ const ItemSize = () => { (!isRenaming || !item.selected); const bytes = useMemo( - () => showSize && byteSize(filePath?.size_in_bytes_bytes), + () => showSize && humanizeSize(filePath?.size_in_bytes_bytes), [filePath?.size_in_bytes_bytes, showSize] ); diff --git a/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx b/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx index d29195c37..e65842230 100644 --- a/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView/useTable.tsx @@ -10,11 +10,11 @@ import dayjs from 'dayjs'; import { memo, useMemo } from 'react'; import { stringify } from 'uuid'; import { - byteSize, getExplorerItemData, getIndexedItemFilePath, getItemFilePath, getItemObject, + humanizeSize, useSelector, type ExplorerItem } from '@sd/client'; @@ -122,7 +122,7 @@ export const useTable = () => { !filePath.size_in_bytes_bytes || (filePath.is_dir && item.type === 'NonIndexedPath') ? '-' - : byteSize(filePath.size_in_bytes_bytes); + : humanizeSize(filePath.size_in_bytes_bytes); } }, { diff --git a/interface/app/$libraryId/overview/LibraryStats.tsx b/interface/app/$libraryId/overview/LibraryStats.tsx index 5257fee75..2e6c0282f 100644 --- a/interface/app/$libraryId/overview/LibraryStats.tsx +++ b/interface/app/$libraryId/overview/LibraryStats.tsx @@ -1,7 +1,7 @@ import { Info } from '@phosphor-icons/react'; import clsx from 'clsx'; import { useEffect, useState } from 'react'; -import { byteSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client'; +import { humanizeSize, Statistics, useLibraryContext, useLibraryQuery } from '@sd/client'; import { Tooltip } from '@sd/ui'; import { useCounter, useLocale } from '~/hooks'; @@ -23,7 +23,7 @@ const StatItem = (props: StatItemProps) => { // The acts as a cache of the value of `mounted` on the first render of this `StateItem`. const [isMounted] = useState(mounted); - const size = byteSize(bytes); + const size = humanizeSize(bytes); const count = useCounter({ name: title, end: size.value, diff --git a/interface/app/$libraryId/overview/LocationCard.tsx b/interface/app/$libraryId/overview/LocationCard.tsx index 069524fb6..cd2b3479b 100644 --- a/interface/app/$libraryId/overview/LocationCard.tsx +++ b/interface/app/$libraryId/overview/LocationCard.tsx @@ -1,6 +1,6 @@ import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; import { useMemo } from 'react'; -import { byteSize } from '@sd/client'; +import { humanizeSize } from '@sd/client'; import { Button, Card, tw } from '@sd/ui'; import { Icon } from '~/components'; @@ -17,7 +17,7 @@ const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull const LocationCard = ({ icon, name, connectionType, ...stats }: LocationCardProps) => { const { totalSpace } = useMemo(() => { return { - totalSpace: byteSize(stats.totalSpace) + totalSpace: humanizeSize(stats.totalSpace) }; }, [stats]); diff --git a/interface/app/$libraryId/overview/StatCard.tsx b/interface/app/$libraryId/overview/StatCard.tsx index ccc2097d6..d2c546cee 100644 --- a/interface/app/$libraryId/overview/StatCard.tsx +++ b/interface/app/$libraryId/overview/StatCard.tsx @@ -1,6 +1,6 @@ import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; import { useEffect, useMemo, useState } from 'react'; -import { byteSize } from '@sd/client'; +import { humanizeSize } from '@sd/client'; import { Button, Card, CircularProgress, tw } from '@sd/ui'; import { Icon } from '~/components'; import { useIsDark, useLocale } from '~/hooks'; @@ -22,12 +22,12 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => { const isDark = useIsDark(); const { totalSpace, freeSpace, usedSpaceSpace } = useMemo(() => { - const totalSpace = byteSize(stats.totalSpace); - const freeSpace = stats.freeSpace == null ? totalSpace : byteSize(stats.freeSpace); + const totalSpace = humanizeSize(stats.totalSpace); + const freeSpace = stats.freeSpace == null ? totalSpace : humanizeSize(stats.freeSpace); return { totalSpace, freeSpace, - usedSpaceSpace: byteSize(totalSpace.original - freeSpace.original) + usedSpaceSpace: humanizeSize(totalSpace.original - freeSpace.original) }; }, [stats]); @@ -36,7 +36,7 @@ const StatCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => { }, []); const progress = useMemo(() => { - if (!mounted || totalSpace.original === 0n) return 0; + if (!mounted || totalSpace.original === 0) return 0; return Math.floor((usedSpaceSpace.value / totalSpace.value) * 100); }, [mounted, totalSpace, usedSpaceSpace]); diff --git a/interface/app/$libraryId/settings/client/usage.tsx b/interface/app/$libraryId/settings/client/usage.tsx index 2a17820a1..dd4007794 100644 --- a/interface/app/$libraryId/settings/client/usage.tsx +++ b/interface/app/$libraryId/settings/client/usage.tsx @@ -1,6 +1,6 @@ import { iconNames } from '@sd/assets/util'; import { memo, useEffect, useMemo, useState } from 'react'; -import { byteSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client'; +import { humanizeSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client'; import { Card } from '@sd/ui'; import { Icon } from '~/components'; import { useCounter, useLocale } from '~/hooks'; @@ -21,10 +21,10 @@ export const Component = () => { const info = useMemo(() => { if (locations.data && discoveredPeers) { const statistics = stats.data?.statistics; - const tb_capacity = byteSize(statistics?.total_bytes_capacity); - const free_space = byteSize(statistics?.total_bytes_free); - const library_db_size = byteSize(statistics?.library_db_size); - const preview_media = byteSize(statistics?.preview_media_bytes); + const tb_capacity = humanizeSize(statistics?.total_bytes_capacity); + const free_space = humanizeSize(statistics?.total_bytes_free); + const library_db_size = humanizeSize(statistics?.library_db_size); + const preview_media = humanizeSize(statistics?.preview_media_bytes); const data: { icon: keyof typeof iconNames; title?: string; diff --git a/interface/app/$libraryId/settings/library/locations/ListItem.tsx b/interface/app/$libraryId/settings/library/locations/ListItem.tsx index cf523ffd9..e1e5cc54c 100644 --- a/interface/app/$libraryId/settings/library/locations/ListItem.tsx +++ b/interface/app/$libraryId/settings/library/locations/ListItem.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; import { arraysEqual, - byteSize, + humanizeSize, Location, useLibraryMutation, useOnlineLocations @@ -66,10 +66,10 @@ export default ({ location }: Props) => { }} > - {byteSize(location.size_in_bytes).value} + {humanizeSize(location.size_in_bytes).value} - {t(`size_${byteSize(location.size_in_bytes).unit.toLowerCase()}`)} + {t(`size_${humanizeSize(location.size_in_bytes).unit.toLowerCase()}`)}
diff --git a/interface/locales/README.md b/interface/locales/README.md index fa4a0e9be..81ba86c75 100644 --- a/interface/locales/README.md +++ b/interface/locales/README.md @@ -31,8 +31,6 @@ const dayjsLocales: Record = { ## Syncing locales -This command will help you sync locales with the source language (en) and find missing keys. +This command will help you sync all locales with the source language (en) and update missing keys. -`npx i18next-locales-sync -p en -s it -l ./interface/locales` - -replace `it` with the language you want to sync with the source language. +`pnpm i18n:sync` diff --git a/interface/locales/ar/common.json b/interface/locales/ar/common.json index 09070273a..7067a3c17 100644 --- a/interface/locales/ar/common.json +++ b/interface/locales/ar/common.json @@ -53,6 +53,7 @@ "changelog": "سجل التغييرات", "changelog_page_description": "انظر إلى الميزات الجديدة الرائعة التي نقوم بإضافتها", "changelog_page_title": "سجل التغييرات", + "chapters": "Chapters", "checksum": "التحقق من الصحة", "clear_finished_jobs": "مسح الوظائف المنتهية", "client": "العميل", @@ -125,6 +126,7 @@ "debug_mode_description": "تمكين ميزات التصحيح الإضافية داخل التطبيق.", "default": "الافتراضي", "descending": "Descending", + "duration": "Duration", "random": "عشوائي", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -577,6 +579,7 @@ "square_thumbnails": "مصغرات مربعة", "star_on_github": "ضع نجمة على GitHub", "starts_with": "starts with", + "start_time": "Start Time", "stop": "إيقاف", "success": "نجاح", "support": "الدعم", diff --git a/interface/locales/be/common.json b/interface/locales/be/common.json index 834d8ed87..b0ccaadfa 100644 --- a/interface/locales/be/common.json +++ b/interface/locales/be/common.json @@ -53,6 +53,7 @@ "changelog": "Што новага", "changelog_page_description": "Даведайцеся, якія новыя магчымасці мы дадалі", "changelog_page_title": "Спіс змен", + "chapters": "Chapters", "checksum": "Кантрольная сума", "clear_finished_jobs": "Ачысціць скончаныя заданні", "client": "Кліент", @@ -125,6 +126,7 @@ "debug_mode_description": "Уключыце дадатковыя функцыі адладкі ў дадатку.", "default": "Стандартны", "descending": "Па змяншэнні", + "duration": "Duration", "random": "Выпадковы", "ipv4_listeners_error": "Памылка пры стварэнні слухачоў IPv4. Калі ласка, праверце наладкі брандмаўэра!", "ipv4_ipv6_listeners_error": "Памылка пры стварэнні слухачоў IPv4 і IPv6. Калі ласка, праверце наладкі брандмаўэра!", @@ -335,6 +337,7 @@ "kind_one": "Тып", "kind_few": "Тыпа", "kind_many": "Тыпаў", + "kind_other": "Kinds", "label": "Ярлык", "labels": "Ярлыкі", "language": "Мова", @@ -363,6 +366,7 @@ "location_one": "Лакацыя", "location_few": "Лакацыі", "location_many": "Лакацый", + "location_other": "Locations", "location_connected_tooltip": "Лакацыя правяраецца на змены", "location_disconnected_tooltip": "Лакацыя не правяраецца на змены", "location_display_name_info": "Імя гэтага месцазнаходжання, якое будзе адлюстроўвацца на бакавой панэлі. Гэта дзеянне не пераназаве фактычнай тэчкі на дыску.", @@ -569,6 +573,7 @@ "square_thumbnails": "Квадратныя эскізы", "star_on_github": "Паставіць зорку на GitHub", "starts_with": "пачынаецца з", + "start_time": "Start Time", "stop": "Спыніць", "success": "Поспех", "support": "Падтрымка", @@ -587,6 +592,7 @@ "tag_one": "Тэг", "tag_few": "Тэга", "tag_many": "Тэгаў", + "tag_other": "Tags", "tags": "Тэгі", "tags_description": "Кіруйце сваімі тэгамі.", "tags_notice_message": "Гэтаму тэгу не прысвоена ні аднаго элемента.", diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json index e519da914..21571a071 100644 --- a/interface/locales/de/common.json +++ b/interface/locales/de/common.json @@ -53,6 +53,7 @@ "changelog": "Änderungsprotokoll", "changelog_page_description": "Sehe, welche coolen neuen Funktionen wir machen", "changelog_page_title": "Änderungsprotokoll", + "chapters": "Chapters", "checksum": "Prüfsumme", "clear_finished_jobs": "Beendete Aufgaben entfernen", "client": "Client", @@ -125,6 +126,7 @@ "debug_mode_description": "Zusätzliche Debugging-Funktionen in der App aktivieren.", "default": "Standard", "descending": "Absteigend", + "duration": "Duration", "random": "Zufällig", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -565,6 +567,7 @@ "square_thumbnails": "Quadratische Vorschaubilder", "star_on_github": "Auf GitHub als Favorit markieren", "starts_with": "beginnt mit", + "start_time": "Start Time", "stop": "Stoppen", "success": "Erfolg", "support": "Unterstützung", diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 8b70c5bfe..15630c2a4 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -53,6 +53,7 @@ "changelog": "Changelog", "changelog_page_description": "See what cool new features we're making", "changelog_page_title": "Changelog", + "chapters": "Chapters", "checksum": "Checksum", "clear_finished_jobs": "Clear out finished jobs", "client": "Client", @@ -125,6 +126,7 @@ "debug_mode_description": "Enable extra debugging features within the app.", "default": "Default", "descending": "Descending", + "duration": "Duration", "random": "Random", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -565,6 +567,7 @@ "square_thumbnails": "Square Thumbnails", "star_on_github": "Star on GitHub", "starts_with": "starts with", + "start_time": "Start Time", "stop": "Stop", "success": "Success", "support": "Support", diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json index 4316272ea..58a5f380c 100644 --- a/interface/locales/es/common.json +++ b/interface/locales/es/common.json @@ -53,6 +53,7 @@ "changelog": "Registro de cambios", "changelog_page_description": "Mira qué nuevas funciones geniales estamos creando", "changelog_page_title": "Registro de cambios", + "chapters": "Chapters", "checksum": "Suma de verificación", "clear_finished_jobs": "Eliminar trabajos finalizados", "client": "Cliente", @@ -125,6 +126,7 @@ "debug_mode_description": "Habilitar funciones de depuración adicionales dentro de la aplicación.", "default": "Predeterminado", "descending": "Descendente", + "duration": "Duration", "random": "Aleatorio", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -332,6 +334,7 @@ "kilometers": "Kilómetros", "kind": "Tipo", "kind_one": "Tipo", + "kind_many": "Kinds", "kind_other": "Tipos", "label": "Etiqueta", "labels": "Etiquetas", @@ -359,6 +362,7 @@ "local_node": "Nodo Local", "location": "Ubicación", "location_one": "Ubicación", + "location_many": "Locations", "location_other": "Ubicaciones", "location_connected_tooltip": "La ubicación está siendo vigilada en busca de cambios", "location_disconnected_tooltip": "La ubicación no está siendo vigilada en busca de cambios", @@ -566,6 +570,7 @@ "square_thumbnails": "Miniaturas Cuadradas", "star_on_github": "Dar estrella en GitHub", "starts_with": "comienza con", + "start_time": "Start Time", "stop": "Detener", "success": "Éxito", "support": "Soporte", @@ -582,6 +587,7 @@ "system": "Sistema", "tag": "Etiqueta", "tag_one": "Etiqueta", + "tag_many": "Tags", "tag_other": "Etiquetas", "tags": "Etiquetas", "tags_description": "Administra tus etiquetas.", diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json index 72a5dcf29..450aa2953 100644 --- a/interface/locales/fr/common.json +++ b/interface/locales/fr/common.json @@ -53,6 +53,7 @@ "changelog": "Journal des modifications", "changelog_page_description": "Découvrez les nouvelles fonctionnalités cool que nous développons", "changelog_page_title": "Changelog", + "chapters": "Chapters", "checksum": "Somme de contrôle", "clear_finished_jobs": "Effacer les travaux terminés", "client": "Client", @@ -125,6 +126,7 @@ "debug_mode_description": "Activez des fonctionnalités de débogage supplémentaires dans l'application.", "default": "Défaut", "descending": "Descente", + "duration": "Duration", "random": "Aléatoire", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -332,6 +334,7 @@ "kilometers": "Kilomètres", "kind": "Type", "kind_one": "Type", + "kind_many": "Kinds", "kind_other": "Types", "label": "Étiquette", "labels": "Étiquettes", @@ -359,6 +362,7 @@ "local_node": "Nœud local", "location": "Localisation", "location_one": "Localisation", + "location_many": "Locations", "location_other": "Localisation", "location_connected_tooltip": "L'emplacement est surveillé pour les changements", "location_disconnected_tooltip": "L'emplacement n'est pas surveillé pour les changements", @@ -566,6 +570,7 @@ "square_thumbnails": "Vignettes carrées", "star_on_github": "Mettre une étoile sur GitHub", "starts_with": "commence par", + "start_time": "Start Time", "stop": "Arrêter", "success": "Succès", "support": "Support", @@ -582,7 +587,9 @@ "system": "Système", "tag": "Étiquette", "tag_one": "Étiquette", + "tag_many": "Tags", "tag_other": "Étiquettes", + "tags": "Tags", "tags_description": "Gérer vos étiquettes.", "tags_notice_message": "Aucun élément attribué à cette balise.", "telemetry_description": "Activez pour fournir aux développeurs des données détaillées d'utilisation et de télémesure afin d'améliorer l'application. Désactivez pour n'envoyer que les données de base : votre statut d'activité, la version de l'application, la version du noyau et la plateforme (par exemple, mobile, web ou ordinateur de bureau).", diff --git a/interface/locales/it/common.json b/interface/locales/it/common.json index 65e365eb5..d003cee87 100644 --- a/interface/locales/it/common.json +++ b/interface/locales/it/common.json @@ -53,6 +53,7 @@ "changelog": "Changelog", "changelog_page_description": "Scopri quali nuove fantastiche funzionalità stiamo realizzando", "changelog_page_title": "Changelog", + "chapters": "Chapters", "checksum": "Checksum", "clear_finished_jobs": "Cancella i lavori completati", "client": "Client", @@ -125,6 +126,7 @@ "debug_mode_description": "Abilita funzionalità di debug aggiuntive all'interno dell'app.", "default": "Predefinito", "descending": "In discesa", + "duration": "Duration", "random": "Casuale", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -332,6 +334,7 @@ "kilometers": "Kilometri", "kind": "Tipo", "kind_one": "Tipo", + "kind_many": "Kinds", "kind_other": "Tipi", "label": "Etichetta", "labels": "Etichette", @@ -359,6 +362,7 @@ "local_node": "Nodo Locale", "location": "Posizione", "location_one": "Posizione", + "location_many": "Locations", "location_other": "Luoghi", "location_connected_tooltip": "La posizione è monitorata per i cambiamenti", "location_disconnected_tooltip": "La posizione non è monitorata per i cambiamenti", @@ -566,6 +570,7 @@ "square_thumbnails": "Miniature quadrate", "star_on_github": "Aggiungi ai preferiti su GitHub", "starts_with": "inizia con", + "start_time": "Start Time", "stop": "Stop", "success": "Successo", "support": "Supporto", @@ -582,7 +587,9 @@ "system": "Sistema", "tag": "Tag", "tag_one": "Tag", + "tag_many": "Tags", "tag_other": "Tags", + "tags": "Tags", "tags_description": "Gestisci i tuoi tags.", "tags_notice_message": "Nessun elemento assegnato a questo tag.", "telemetry_description": "Attiva per fornire agli sviluppatori dati dettagliati sull'utilizzo e sulla telemetria per migliorare l'app. Disattiva per inviare solo i dati di base: stato della tua attività, versione dell'app, versione principale e piattaforma (ad esempio mobile, web o desktop).", diff --git a/interface/locales/ja/common.json b/interface/locales/ja/common.json index fc5a74362..ff40b101d 100644 --- a/interface/locales/ja/common.json +++ b/interface/locales/ja/common.json @@ -53,6 +53,7 @@ "changelog": "変更履歴", "changelog_page_description": "Spacedriveの魅力ある新機能をご確認ください。", "changelog_page_title": "変更履歴", + "chapters": "Chapters", "checksum": "チェックサム", "clear_finished_jobs": "完了ジョブを削除", "client": "クライアント", @@ -125,6 +126,7 @@ "debug_mode_description": "アプリ内で追加のデバッグ機能を有効にします。", "default": "デフォルト", "descending": "下降", + "duration": "Duration", "random": "ランダム", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -561,6 +563,7 @@ "square_thumbnails": "正方形のサムネイル", "star_on_github": "GitHub上のスター", "starts_with": "で始まる。", + "start_time": "Start Time", "stop": "中止", "success": "成功", "support": "サポート", diff --git a/interface/locales/nl/common.json b/interface/locales/nl/common.json index cf7b00035..1bf9b950c 100644 --- a/interface/locales/nl/common.json +++ b/interface/locales/nl/common.json @@ -53,6 +53,7 @@ "changelog": "Wijzigingslogboek", "changelog_page_description": "Zie welke coole nieuwe functies we aan het maken zijn", "changelog_page_title": "Wijzigingslogboek", + "chapters": "Chapters", "checksum": "Controlegetal", "clear_finished_jobs": "Ruim voltooide taken op", "client": "Client", @@ -125,6 +126,7 @@ "debug_mode_description": "Schakel extra debugging functies in de app in.", "default": "Standaard", "descending": "Aflopend", + "duration": "Duration", "random": "Willekeurig", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -565,6 +567,7 @@ "square_thumbnails": "Vierkante Miniaturen", "star_on_github": "Ster op GitHub", "starts_with": "begint met", + "start_time": "Start Time", "stop": "Stop", "success": "Succes", "support": "Ondersteuning", diff --git a/interface/locales/ru/common.json b/interface/locales/ru/common.json index 7b7f0418b..b98fb9704 100644 --- a/interface/locales/ru/common.json +++ b/interface/locales/ru/common.json @@ -53,6 +53,7 @@ "changelog": "Что нового", "changelog_page_description": "Узнайте, какие новые возможности мы добавили", "changelog_page_title": "Список изменений", + "chapters": "Chapters", "checksum": "Контрольная сумма", "clear_finished_jobs": "Очистить законченные задачи", "client": "Клиент", @@ -125,6 +126,7 @@ "debug_mode_description": "Включите дополнительные функции отладки в приложении.", "default": "Стандартный", "descending": "По убыванию", + "duration": "Duration", "random": "Случайный", "ipv4_listeners_error": "Ошибка при создании слушателей IPv4. Пожалуйста, проверьте настройки брандмауэра!", "ipv4_ipv6_listeners_error": "Ошибка при создании слушателей IPv4 и IPv6. Пожалуйста, проверьте настройки брандмауэра!", @@ -335,6 +337,7 @@ "kind_one": "Тип", "kind_few": "Типа", "kind_many": "Типов", + "kind_other": "Kinds", "label": "Ярлык", "labels": "Ярлыки", "language": "Язык", @@ -363,6 +366,7 @@ "location_one": "Локация", "location_few": "Локации", "location_many": "Локаций", + "location_other": "Locations", "location_connected_tooltip": "Локация проверяется на изменения", "location_disconnected_tooltip": "Локация не проверяется на изменения", "location_display_name_info": "Имя этого месторасположения, которое будет отображаться на боковой панели. Это действие не переименует фактическую папку на диске.", @@ -569,6 +573,7 @@ "square_thumbnails": "Квадратные эскизы", "star_on_github": "Поставить звезду на GitHub", "starts_with": "начинается с", + "start_time": "Start Time", "stop": "Остановить", "success": "Успех", "support": "Поддержка", @@ -587,6 +592,7 @@ "tag_one": "Тег", "tag_few": "Тега", "tag_many": "Тегов", + "tag_other": "Tags", "tags": "Теги", "tags_description": "Управляйте своими тегами.", "tags_notice_message": "Этому тегу не присвоено ни одного элемента.", diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json index 6002dfbc2..8c71ebaa7 100644 --- a/interface/locales/tr/common.json +++ b/interface/locales/tr/common.json @@ -53,6 +53,7 @@ "changelog": "Değişiklikler", "changelog_page_description": "Yaptığımız havalı yeni özellikleri görün", "changelog_page_title": "Değişiklikler", + "chapters": "Chapters", "checksum": "Kontrol Toplamı", "clear_finished_jobs": "Biten işleri temizle", "client": "İstemci", @@ -125,6 +126,7 @@ "debug_mode_description": "Uygulama içinde ek hata ayıklama özelliklerini etkinleştir.", "default": "Varsayılan", "descending": "Alçalma", + "duration": "Duration", "random": "Rastgele", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -565,6 +567,7 @@ "square_thumbnails": "Kare Küçük Resimler", "star_on_github": "GitHub'da Yıldızla", "starts_with": "ile başlar", + "start_time": "Start Time", "stop": "Durdur", "success": "Başarılı", "support": "Destek", diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json index cec91842a..85736044b 100644 --- a/interface/locales/zh-CN/common.json +++ b/interface/locales/zh-CN/common.json @@ -53,6 +53,7 @@ "changelog": "更新日志", "changelog_page_description": "看看我们在开发哪些酷炫的新功能", "changelog_page_title": "更新日志", + "chapters": "Chapters", "checksum": "校验和", "clear_finished_jobs": "清除已完成的任务", "client": "客户端", @@ -125,6 +126,7 @@ "debug_mode_description": "启用本应用额外的调试功能。", "default": "默认", "descending": "降序", + "duration": "Duration", "random": "随机的", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -561,6 +563,7 @@ "square_thumbnails": "方形缩略图", "star_on_github": "在 GitHub 上送一个 star", "starts_with": "以。。开始", + "start_time": "Start Time", "stop": "停止", "success": "成功", "support": "支持", diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json index 88b207ed9..493e2c4ad 100644 --- a/interface/locales/zh-TW/common.json +++ b/interface/locales/zh-TW/common.json @@ -53,6 +53,7 @@ "changelog": "變更日誌", "changelog_page_description": "了解我們正在創建的酷炫新功能", "changelog_page_title": "變更日誌", + "chapters": "Chapters", "checksum": "校驗和", "clear_finished_jobs": "清除已完成的工作", "client": "客戶端", @@ -125,6 +126,7 @@ "debug_mode_description": "在應用程序中啟用額外的除錯功能。", "default": "默認", "descending": "降序", + "duration": "Duration", "random": "隨機的", "ipv4_listeners_error": "Error creating the IPv4 listeners. Please check your firewall settings!", "ipv4_ipv6_listeners_error": "Error creating the IPv4 and IPv6 listeners. Please check your firewall settings!", @@ -561,6 +563,7 @@ "square_thumbnails": "方形縮略圖", "star_on_github": "在GitHub上給星", "starts_with": "以。", + "start_time": "Start Time", "stop": "停止", "success": "成功", "support": "支持", diff --git a/interface/util/keybinds.ts b/interface/util/keybinds.ts index 10b7563a0..29e7419e4 100644 --- a/interface/util/keybinds.ts +++ b/interface/util/keybinds.ts @@ -1,11 +1,8 @@ +import { capitalize } from '@sd/client'; import { keySymbols, ModifierKeys, modifierSymbols } from '@sd/ui'; import { OperatingSystem } from '../util/Platform'; -function capitalize(string: T): Capitalize { - return (string.charAt(0).toUpperCase() + string.slice(1)) as Capitalize; -} - export function keybind( modifers: ModifierKeys[], keys: T[], diff --git a/package.json b/package.json index 4f84826c8..f8ad78c65 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "lint": "turbo run lint", "lint:fix": "turbo run lint -- --fix", "clean": "cargo clean; git clean -qfX .", - "test-data": "./scripts/test-data.sh" + "test-data": "./scripts/test-data.sh", + "i18n:sync": "npx i18next-locales-sync -p en -s $(find ./interface/locales -wholename '*/common.json' | awk -F'/' '$4 != \"en\" { ORS=\" \"; print $4 }') -l ./interface/locales" }, "pnpm": { "patchedDependencies": { @@ -68,6 +69,6 @@ "eslintConfig": { "root": true }, - "packageManager": "pnpm@9.0.6", + "packageManager": "pnpm@9.1.0", "engineStrict": false } diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index dfe0df426..3f591b425 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -10,10 +10,10 @@ export type Procedures = { { key: "cloud.library.get", input: LibraryArgs, result: CloudLibrary | null } | { key: "cloud.library.list", input: never, result: CloudLibrary[] } | { key: "cloud.locations.list", input: never, result: CloudLocation[] } | - { key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } | + { key: "ephemeralFiles.getMediaData", input: string, result: MediaData | null } | { key: "files.get", input: LibraryArgs, result: ObjectWithFilePaths2 | null } | { key: "files.getConvertibleImageExtensions", input: never, result: string[] } | - { key: "files.getMediaData", input: LibraryArgs, result: MediaMetadata } | + { key: "files.getMediaData", input: LibraryArgs, result: MediaData } | { key: "files.getPath", input: LibraryArgs, result: string | null } | { key: "invalidation.test-invalidate", input: never, result: number } | { key: "jobs.isActive", input: LibraryArgs, result: boolean } | @@ -149,7 +149,7 @@ export type Procedures = { export type Args = { search?: string | null; filters?: string | null; name?: string | null; icon?: string | null; description?: string | null } -export type AudioMetadata = { duration: number | null; audio_codec: string | null } +export type AudioProps = { delay: number; padding: number; sample_rate: number | null; sample_format: string | null; bit_per_sample: number | null; channel_layout: string | null } /** * All of the feature flags provided by the core itself. The frontend has it's own set of feature flags! @@ -166,16 +166,20 @@ export type CRDTOperation = { instance: string; timestamp: number; model: number export type CRDTOperationData = { c: { [key in string]: JsonValue } } | { u: { field: string; value: JsonValue } } | "d" -export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null } +export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null } export type ChangeNodeNameArgs = { name: string | null; p2p_port: Port | null; p2p_ipv4_enabled: boolean | null; p2p_ipv6_enabled: boolean | null; p2p_discovery: P2PDiscoveryState | null; p2p_remote_access: boolean | null; image_labeler_version: string | null } +export type Chapter = { id: number; start: [number, number]; end: [number, number]; time_base_den: number; time_base_num: number; metadata: Metadata } + export type CloudInstance = { id: string; uuid: string; identity: RemoteIdentity; nodeId: string; metadata: { [key in string]: string } } export type CloudLibrary = { id: string; uuid: string; name: string; instances: CloudInstance[]; ownerId: string } export type CloudLocation = { id: string; name: string } +export type Codec = { kind: string | null; sub_kind: string | null; tag: string | null; name: string | null; profile: string | null; bit_rate: number; props: Props | null } + export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait" export type Composite = @@ -257,14 +261,26 @@ export type Error = { code: ErrorCode; message: string } */ export type ErrorCode = "BadRequest" | "Unauthorized" | "Forbidden" | "NotFound" | "Timeout" | "Conflict" | "PreconditionFailed" | "PayloadTooLarge" | "MethodNotSupported" | "ClientClosedRequest" | "InternalServerError" +export type ExifDataOrder = { field: "epochTime"; value: SortOrder } + +export type ExifMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } + export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: ObjectWithFilePaths } | { type: "NonIndexedPath"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: NonIndexedPathItem } | { type: "Location"; item: Location } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } export type ExplorerLayout = "grid" | "list" | "media" export type ExplorerSettings = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key in string]: boolean } | null; colSizes: { [key in string]: number } | null; listViewIconSize: string | null; listViewTextSize: string | null; order?: TOrder | null; showHiddenFiles?: boolean } +export type FFmpegMetadata = { formats: string[]; duration: [number, number] | null; start_time: [number, number] | null; bit_rate: [number, number]; chapters: Chapter[]; programs: Program[]; metadata: Metadata } + export type Feedback = { message: string; emoji: number } +export type FfmpegMediaAudioProps = { id: number; delay: number; padding: number; sample_rate: number | null; sample_format: string | null; bit_per_sample: number | null; channel_layout: string | null; codec_id: number } + +export type FfmpegMediaChapter = { chapter_id: number; start: number[]; end: number[]; time_base_den: number; time_base_num: number; title: string | null; metadata: number[] | null; ffmpeg_data_id: number } + +export type FfmpegMediaVideoProps = { id: number; pixel_format: string | null; color_range: string | null; bits_per_channel: number | null; color_space: string | null; color_primaries: string | null; color_transfer: string | null; field_order: string | null; chroma_location: string | null; width: number; height: number; aspect_ratio_num: number | null; aspect_ratio_Den: number | null; properties: string | null; codec_id: number } + export type FileCreateContextTypes = "empty" | "text" export type FilePath = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null } @@ -281,7 +297,7 @@ export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "size export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean } -export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; media_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null } +export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; exif_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null } export type Flash = { /** @@ -342,8 +358,6 @@ export type HardwareModel = "Other" | "MacStudio" | "MacBookAir" | "MacBookPro" export type IdentifyUniqueFilesArgs = { id: number; path: string } -export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } - export type InOrNotIn = { in: T[] } | { notIn: T[] } export type IndexerRule = { id: number; pub_id: number[]; name: string | null; default: boolean | null; rules_per_kind: number[] | null; date_created: string | null; date_modified: string | null } @@ -446,7 +460,7 @@ export type LocationWithIndexerRule = { id: number; pub_id: number[]; name: stri export type MaybeUndefined = null | T -export type MediaDataOrder = { field: "epochTime"; value: SortOrder } +export type MediaData = { Exif: ExifMetadata } | { FFmpeg: FFmpegMetadata } /** * This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC (`YYYY-MM-DD HH-MM-SS ±HHMM`), @@ -456,7 +470,7 @@ export type MediaDate = string export type MediaLocation = { latitude: number; longitude: number; pluscode: PlusCode; altitude: number | null; direction: number | null } -export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) +export type Metadata = { album: string | null; album_artist: string | null; artist: string | null; comment: string | null; composer: string | null; copyright: string | null; creation_time: string | null; date: string | null; disc: number | null; encoder: string | null; encoded_by: string | null; filename: string | null; genre: string | null; language: string | null; performer: string | null; publisher: string | null; service_name: string | null; service_provider: string | null; title: string | null; track: number | null; variant_bit_rate: number | null; custom: { [key in string]: string } } export type NodeConfigP2P = { discovery?: P2PDiscoveryState; port: Port; ipv4: boolean; ipv6: boolean; remote_access: boolean } @@ -497,15 +511,15 @@ export type ObjectFilterArgs = { favorite: boolean } | { hidden: ObjectHiddenFil export type ObjectHiddenFilter = "exclude" | "include" -export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder } | { field: "mediaData"; value: MediaDataOrder } +export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder } | { field: "mediaData"; value: ExifDataOrder } export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination | null; filters?: SearchFilterArgs[] } export type ObjectValidatorArgs = { id: number; path: string } -export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; media_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null })[] } +export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; exif_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null; ffmpeg_data: { id: number; formats: string; bit_rate: number[]; duration: number[] | null; start_time: number[] | null; title: string | null; creation_time: string | null; date: string | null; album_artist: string | null; disc: string | null; track: string | null; album: string | null; artist: string | null; metadata: number[] | null; object_id: number; chapters: FfmpegMediaChapter[]; programs: ({ program_id: number; name: string | null; metadata: number[] | null; ffmpeg_data_id: number; streams: ({ stream_id: number; name: string | null; aspect_ratio_num: number; aspect_ratio_den: number; frames_per_second_num: number; frames_per_second_den: number; time_base_real_den: number; time_base_real_num: number; dispositions: string | null; title: string | null; encoder: string | null; language: string | null; duration: number[] | null; metadata: number[] | null; program_id: number; ffmpeg_data_id: number; codec: { id: number; kind: string | null; sub_kind: string | null; tag: string | null; name: string | null; profile: string | null; bit_rate: number; stream_id: number; program_id: number; ffmpeg_data_id: number; audio_props: FfmpegMediaAudioProps | null; video_props: FfmpegMediaVideoProps | null } | null })[] })[] } | null } | null })[] } -export type ObjectWithFilePaths2 = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; media_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null })[] } +export type ObjectWithFilePaths2 = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; exif_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null; ffmpeg_data: { id: number; formats: string; bit_rate: number[]; duration: number[] | null; start_time: number[] | null; title: string | null; creation_time: string | null; date: string | null; album_artist: string | null; disc: string | null; track: string | null; album: string | null; artist: string | null; metadata: number[] | null; object_id: number; chapters: FfmpegMediaChapter[]; programs: ({ program_id: number; name: string | null; metadata: number[] | null; ffmpeg_data_id: number; streams: ({ stream_id: number; name: string | null; aspect_ratio_num: number; aspect_ratio_den: number; frames_per_second_num: number; frames_per_second_den: number; time_base_real_den: number; time_base_real_num: number; dispositions: string | null; title: string | null; encoder: string | null; language: string | null; duration: number[] | null; metadata: number[] | null; program_id: number; ffmpeg_data_id: number; codec: { id: number; kind: string | null; sub_kind: string | null; tag: string | null; name: string | null; profile: string | null; bit_rate: number; stream_id: number; program_id: number; ffmpeg_data_id: number; audio_props: FfmpegMediaAudioProps | null; video_props: FfmpegMediaVideoProps | null } | null })[] })[] } | null } | null })[] } export type OldFileCopierJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string } @@ -535,6 +549,10 @@ export type PlusCode = string export type Port = { type: "random" } | { type: "discrete"; value: number } +export type Program = { id: number; name: string | null; streams: Stream[]; metadata: Metadata } + +export type Props = { Video: VideoProps } | { Audio: AudioProps } | { Subtitle: SubtitleProps } + export type Range = { from: T } | { to: T } export type RemoteIdentity = string @@ -581,6 +599,10 @@ export type Statistics = { id: number; date_captured: string; total_object_count export type StatisticsResponse = { statistics: Statistics | null } +export type Stream = { id: number; name: string | null; codec: Codec | null; aspect_ratio_num: number; aspect_ratio_den: number; frames_per_second_num: number; frames_per_second_den: number; time_base_real_den: number; time_base_real_num: number; dispositions: string[]; metadata: Metadata } + +export type SubtitleProps = { width: number; height: number } + export type SyncStatus = { ingest: boolean; cloud_send: boolean; cloud_receive: boolean; cloud_ingest: boolean } export type SystemLocations = { desktop: string | null; documents: string | null; downloads: string | null; pictures: string | null; music: string | null; videos: string | null } @@ -603,6 +625,6 @@ export type ThumbnailerPreferences = { background_processing_percentage: number export type UpdateThumbnailerPreferences = { background_processing_percentage: number } -export type VideoMetadata = { duration: number | null; video_codec: string | null; audio_codec: string | null } +export type VideoProps = { pixel_format: string | null; color_range: string | null; bits_per_channel: number | null; color_space: string | null; color_primaries: string | null; color_transfer: string | null; field_order: string | null; chroma_location: string | null; width: number; height: number; aspect_ratio_num: number | null; aspect_ratio_den: number | null; properties: string[] } export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean } diff --git a/packages/client/src/lib/byte-size.ts b/packages/client/src/lib/byte-size.ts deleted file mode 100644 index 6d1a0ca4a..000000000 --- a/packages/client/src/lib/byte-size.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Inspired by: https://github.com/75lb/byte-size - -const DECIMAL_UNITS = [ - { short: 'B', long: 'bytes', from: 0n }, - { short: 'kB', long: 'kilobytes', from: 1000n }, - { short: 'MB', long: 'megabytes', from: 1000n ** 2n }, - { short: 'GB', long: 'gigabytes', from: 1000n ** 3n }, - { short: 'TB', long: 'terabytes', from: 1000n ** 4n }, - { short: 'PB', long: 'petabytes', from: 1000n ** 5n }, - { short: 'EB', long: 'exabytes', from: 1000n ** 6n }, - { short: 'ZB', long: 'zettabytes', from: 1000n ** 7n }, - { short: 'YB', long: 'yottabytes', from: 1000n ** 8n }, - { short: 'RB', long: 'ronnabyte', from: 1000n ** 9n }, - { short: 'QB', long: 'quettabyte', from: 1000n ** 10n } -]; - -const getDecimalUnit = (n: bigint) => { - const s = n.toString(10); - const log10 = s.length + Math.log10(Number('0.' + s.substring(0, 15))); - const index = (log10 / 3) | 0; - return ( - DECIMAL_UNITS[index] ?? - (DECIMAL_UNITS[DECIMAL_UNITS.length - 1] as Exclude< - (typeof DECIMAL_UNITS)[number], - undefined - >) - ); -}; - -export function bytesToNumber(bytes: string[] | number[] | bigint[]) { - return bytes - .map((b) => (typeof b === 'bigint' ? b : BigInt(b))) - .reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1)); -} - -export interface ByteSizeOpts { - locales?: string | string[]; - precision: number; -} - -/** - * Returns an object with the spec `{ value: string, unit: string, long: string }`. The returned object defines a `toString` method meaning it can be used in any string context. - * - * @param value - The bytes value to convert. - * @param options - Optional config. - * @param options.locales - The locale to use for number formatting (e.g. `'de-DE'`). Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). - * @param options.precision - Number of decimal places. Defaults to `1`. - */ -export const byteSize = ( - value: null | string | number | bigint | string[] | number[] | bigint[] | undefined, - { precision, locales }: ByteSizeOpts = { precision: 1 } -) => { - if (value == null) value = 0n; - if (Array.isArray(value)) value = bytesToNumber(value); - else if (typeof value !== 'bigint') value = BigInt(value); - const [isNegative, bytes] = value < 0n ? [true, -value] : [false, value]; - - const unit = getDecimalUnit(bytes); - const defaultFormat = new Intl.NumberFormat(locales, { - style: 'decimal', - minimumFractionDigits: precision, - maximumFractionDigits: precision - }); - const precisionFactor = 10 ** precision; - return { - unit: unit.short, - long: unit.long, - value: - (isNegative ? -1 : 1) * - (unit.from === 0n - ? Number(bytes) - : Number((bytes * BigInt(precisionFactor)) / unit.from) / precisionFactor), - original: value, - toString() { - return `${defaultFormat.format(this.value)} ${this.unit}`; - } - }; -}; diff --git a/packages/client/src/lib/explorerItem.ts b/packages/client/src/lib/explorerItem.ts index fb80fd30e..5b2dfaab7 100644 --- a/packages/client/src/lib/explorerItem.ts +++ b/packages/client/src/lib/explorerItem.ts @@ -1,6 +1,6 @@ import type { ExplorerItem } from '../core'; import { getItemFilePath, getItemLocation, getItemObject } from '../utils'; -import { byteSize } from './byte-size'; +import { humanizeSize } from './humanizeSize'; import { ObjectKind, ObjectKindKey } from './objectKind'; // ItemData is a single data structure understood by the Explorer, we map all ExplorerItems to this structure in this file @@ -8,7 +8,7 @@ import { ObjectKind, ObjectKindKey } from './objectKind'; export interface ItemData { name: string | null; fullName: string | null; - size: ReturnType; + size: ReturnType; kind: ObjectKindKey; isDir: boolean; casId: string | null; @@ -43,8 +43,8 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { else if (data.type === 'NonIndexedPath') itemData.kind = ObjectKind[data.item.kind] ?? 'Unknown'; - if (object && 'media_data' in object && object.media_data?.media_date) { - const byteArray = object.media_data.media_date; + if (object && 'exif_data' in object && object.exif_data?.media_date) { + const byteArray = object.exif_data.media_date; const dateString = String.fromCharCode.apply(null, byteArray); const [date, time] = dateString.replace(/"/g, '').split(' '); if (date && time) itemData.dateTaken = `${date}T${time}Z`; @@ -66,7 +66,7 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { if (filePath) { itemData.name = filePath.name; itemData.fullName = getFullName(filePath.name, filePath.extension); - itemData.size = byteSize(filePath.size_in_bytes_bytes); + itemData.size = humanizeSize(filePath.size_in_bytes_bytes); itemData.isDir = filePath.is_dir ?? false; itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null; // @@ -82,7 +82,9 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { const location = getItemLocation(data); if (location) { if (location.total_capacity != null && location.available_capacity != null) - itemData.size = byteSize(location.total_capacity - location.available_capacity); + itemData.size = humanizeSize( + location.total_capacity - location.available_capacity + ); itemData.name = location.name; itemData.fullName = location.name; @@ -122,7 +124,7 @@ function getDefaultItemData(kind: ObjectKindKey = 'Unknown'): ItemData { return { name: null, fullName: null, - size: byteSize(0), + size: humanizeSize(0), kind: 'Unknown', isDir: false, casId: null, diff --git a/packages/client/src/lib/humanizeSize.ts b/packages/client/src/lib/humanizeSize.ts new file mode 100644 index 000000000..cc12c1274 --- /dev/null +++ b/packages/client/src/lib/humanizeSize.ts @@ -0,0 +1,140 @@ +// Inspired by: https://github.com/75lb/byte-size + +const DECIMAL_UNITS = [ + { short: 'B', long: 'byte', from: 0n }, + { short: 'kB', long: 'kilobyte', from: 1000n }, + { short: 'MB', long: 'megabyte', from: 1000n ** 2n }, + { short: 'GB', long: 'gigabyte', from: 1000n ** 3n }, + { short: 'TB', long: 'terabyte', from: 1000n ** 4n }, + { short: 'PB', long: 'petabyte', from: 1000n ** 5n }, + { short: 'EB', long: 'exabyte', from: 1000n ** 6n }, + { short: 'ZB', long: 'zettabyte', from: 1000n ** 7n }, + { short: 'YB', long: 'yottabyte', from: 1000n ** 8n }, + { short: 'RB', long: 'ronnabyte', from: 1000n ** 9n }, + { short: 'QB', long: 'quettabyte', from: 1000n ** 10n } +]; + +const BINARY_UNITS = [ + DECIMAL_UNITS[0], + { short: 'KiB', long: 'kibibyte', from: 1024n }, + { short: 'MiB', long: 'mebibyte', from: 1024n ** 2n }, + { short: 'GiB', long: 'gibibyte', from: 1024n ** 3n }, + { short: 'TiB', long: 'tebibyte', from: 1024n ** 4n }, + { short: 'PiB', long: 'pebibyte', from: 1024n ** 5n }, + { short: 'EiB', long: 'exbibyte', from: 1024n ** 6n }, + { short: 'ZiB', long: 'zebibyte', from: 1024n ** 7n }, + { short: 'YiB', long: 'yobibyte', from: 1024n ** 8n } +]; + +const BYTE_TO_BIT = { + B: 'b', + byte: 'bit', + kB: 'kb', + KiB: 'Kib', + kilobyte: 'kilobit', + kibibyte: 'kibibit', + MB: 'Mb', + MiB: 'Mib', + megabyte: 'megabit', + mebibyte: 'mebibit', + GB: 'Gb', + GiB: 'Gib', + gigabyte: 'gigabit', + gibibyte: 'gibibit', + TB: 'Tb', + TiB: 'Tib', + terabyte: 'terabit', + tebibyte: 'tebibit', + PB: 'Pb', + PiB: 'Pib', + petabyte: 'petabit', + pebibyte: 'pebibit', + EB: 'Eb', + EiB: 'Eib', + exabyte: 'exabit', + exbibyte: 'exbibit', + ZB: 'Zb', + ZiB: 'Zib', + zettabyte: 'zettabit', + zebibyte: 'zebibit', + YB: 'Yb', + YiB: 'Yib', + yottabyte: 'yottabit', + yobibyte: 'yobibit', + RB: 'Rb', + ronnabyte: 'ronnabit', + QB: 'Qb', + quettabyte: 'quettabit' +}; + +const getBaseUnit = (n: bigint, map: typeof DECIMAL_UNITS | typeof BINARY_UNITS) => { + const s = n.toString(10); + const log10 = s.length + Math.log10(Number('0.' + s.substring(0, 15))); + const index = (log10 / 3) | 0; + return map[index] ?? (map[map.length - 1] as Exclude<(typeof map)[number], undefined>); +}; + +export function bytesToNumber(bytes: string[] | number[] | bigint[]) { + return bytes + .map((b) => (typeof b === 'bigint' ? b : BigInt(b))) + .reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1)); +} + +export interface ByteSizeOpts { + is_bit?: boolean; + locales?: string | string[]; + precision?: number; + base_unit?: 'decimal' | 'binary'; + use_plural?: boolean; +} + +/** + * Returns an object with the spec `{ value: string, unit: string, long: string }`. The returned object defines a `toString` method meaning it can be used in any string context. + * + * @param value - The bytes value to convert. + * @param options - Optional config. + * @param options.is_bit - Use bit units names instead of byte units. Defaults to `false`. + * @param options.locales - The locale to use for number formatting (e.g. `'de-DE'`). Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). + * @param options.precision - Number of decimal places. Defaults to `1`. + * @param options.base_unit - The base unit to use. Defaults to `'decimal'`. + * @param options.use_plural - Use plural unit names when necessary. Defaults to `true`. + */ +export const humanizeSize = ( + value: null | string | number | bigint | string[] | number[] | bigint[] | undefined, + { + is_bit = false, + precision = 1, + locales, + base_unit = 'decimal', + use_plural = true + }: ByteSizeOpts = {} +) => { + if (value == null) value = 0n; + if (Array.isArray(value)) value = bytesToNumber(value); + else if (typeof value === 'number') value = BigInt(value | 0); + else if (typeof value !== 'bigint') value = BigInt(value); + const [isNegative, bytes] = value < 0n ? [true, -value] : [false, value]; + + const unit = getBaseUnit(bytes, base_unit === 'decimal' ? DECIMAL_UNITS : BINARY_UNITS); + const defaultFormat = new Intl.NumberFormat(locales, { + style: 'decimal', + minimumFractionDigits: precision, + maximumFractionDigits: precision + }); + const precisionFactor = 10 ** precision; + value = + unit.from === 0n + ? Number(bytes) + : Number((bytes * BigInt(precisionFactor)) / unit.from) / precisionFactor; + const plural = use_plural && value !== 1 ? 's' : ''; + + return { + unit: (is_bit ? BYTE_TO_BIT[unit.short as keyof typeof BYTE_TO_BIT] : unit.short) + plural, + long: (is_bit ? BYTE_TO_BIT[unit.long as keyof typeof BYTE_TO_BIT] : unit.long) + plural, + value: (isNegative ? -1 : 1) * value, + original: value, + toString() { + return `${defaultFormat.format(this.value)} ${this.unit}`; + } + }; +}; diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts index ecb706ba7..4a495f9db 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -1,5 +1,5 @@ export * from './objectKind'; export * from './explorerItem'; -export * from './byte-size'; +export * from './humanizeSize'; export * from './passwordStrength'; export * from './valtio'; diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index c28d1acd9..b4ab41ed4 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -136,3 +136,12 @@ export function insertLibrary(queryClient: QueryClient, library: LibraryConfigWr return [library, ...libraries]; }); } + +// [int32, int32] => BigInt +export function int32ArrayToBigInt([high, low]: [number, number]) { + return (BigInt(high) << 32n) | BigInt(low); +} + +export function capitalize(string: T): Capitalize { + return (string.charAt(0).toUpperCase() + string.slice(1)) as Capitalize; +} diff --git a/packages/client/src/utils/jobs/useJobInfo.tsx b/packages/client/src/utils/jobs/useJobInfo.tsx index 18160596e..a321d0eea 100644 --- a/packages/client/src/utils/jobs/useJobInfo.tsx +++ b/packages/client/src/utils/jobs/useJobInfo.tsx @@ -69,7 +69,7 @@ export function useJobInfo(job: JobReport, realtimeUpdate: JobProgressEvent | nu text: `${ completedTaskCount ? formatNumber(completedTaskCount || 0) - : formatNumber(output?.media_data?.extracted) + : formatNumber(output?.exif_data?.extracted) } of ${formatNumber(taskCount)} ${plural( taskCount, 'media file' @@ -110,7 +110,7 @@ export function useJobInfo(job: JobReport, realtimeUpdate: JobProgressEvent | nu const totalThumbs = output?.thumbs_processed || 0; const totalMediaFiles = - output?.media_data?.extracted || 0 + output?.media_data?.skipped || 0; + output?.exif_data?.extracted || 0 + output?.exif_data?.skipped || 0; return totalThumbs === 0 && totalMediaFiles === 0 ? [{ text: 'None processed' }] diff --git a/packages/ui/src/Dialog.tsx b/packages/ui/src/Dialog.tsx index c4d422669..d388b401a 100644 --- a/packages/ui/src/Dialog.tsx +++ b/packages/ui/src/Dialog.tsx @@ -61,10 +61,8 @@ class DialogManager { const state = this.getState(id); if (!state) { - throw new Error(`Dialog ${id} not registered!`); - } - - if (state.open === false) { + console.error(new Error(`Dialog ${id} not registered!`)); + } else if (state.open === false) { delete this.dialogs[id]; delete this.state[id]; } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 292fe499e..744175d52 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "stable" +channel = "1.78"