feat: add view full info for images

This commit is contained in:
niraj-khatiwada 2026-03-27 20:27:05 +05:45
parent abc0ad72f4
commit b0315c73ba
23 changed files with 1700 additions and 220 deletions

View file

@ -8,6 +8,7 @@
"appimage",
"compresso",
"Deflaters",
"Exif",
"FFPROBE",
"ghostscript",
"gifsicle",

View file

@ -20,7 +20,7 @@
"tauri:build:mac:x64": "tauri build --target x86_64-apple-darwin",
"tauri:build:mac": "pnpm tauri:build:mac:arm64 && pnpm tauri:build:mac:x64",
"generate:latest-json": "node scripts/generate-latest-json.mjs",
"homebrew:release": "./scripts/homebrew-release.sh",
"homebrew:release": "tsx scripts/homebrew-release.mts",
"pnpm:devPreinstall": "fnm use",
"postinstall": "husky install",
"lint-staged": "lint-staged",
@ -73,6 +73,7 @@
"lint-staged": "^13.2.3",
"postcss": "^8.4.31",
"tailwindcss": "^3.4.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^6.0.3",
"vite-plugin-svgr": "^4.3.0"
@ -83,6 +84,8 @@
}
},
"lint-staged": {
"src/**/*.{js,ts,jsx,tsx}": ["biome check --write"]
"src/**/*.{js,ts,jsx,tsx}": [
"biome check --write"
]
}
}

View file

@ -107,7 +107,7 @@ importers:
version: 0.5.19(tailwindcss@3.4.3)
'@tanstack/router-plugin':
specifier: ^1.97.22
version: 1.97.22(@tanstack/react-router@1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2))
version: 1.97.22(@tanstack/react-router@1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0))
'@tauri-apps/cli':
specifier: ^2.9.2
version: 2.9.2
@ -125,7 +125,7 @@ importers:
version: 18.2.15
'@vitejs/plugin-react':
specifier: ^4.3.2
version: 4.3.4(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2))
version: 4.3.4(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0))
autoprefixer:
specifier: ^10.4.16
version: 10.4.16(postcss@8.4.31)
@ -141,15 +141,18 @@ importers:
tailwindcss:
specifier: ^3.4.0
version: 3.4.3
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: ^6.0.3
version: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)
version: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0)
vite-plugin-svgr:
specifier: ^4.3.0
version: 4.3.0(rollup@4.32.0)(typescript@5.9.3)(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2))
version: 4.3.0(rollup@4.32.0)(typescript@5.9.3)(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0))
packages:
@ -402,23 +405,17 @@ packages:
'@emotion/memoize@0.7.4':
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
'@esbuild/aix-ppc64@0.23.1':
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.23.1':
resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
@ -426,10 +423,10 @@ packages:
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.23.1':
resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm]
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.2':
@ -438,10 +435,10 @@ packages:
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.23.1':
resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.2':
@ -450,11 +447,11 @@ packages:
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.23.1':
resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
@ -462,10 +459,10 @@ packages:
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.23.1':
resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.2':
@ -474,11 +471,11 @@ packages:
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.23.1':
resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
@ -486,10 +483,10 @@ packages:
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.23.1':
resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.2':
@ -498,11 +495,11 @@ packages:
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.23.1':
resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
@ -510,10 +507,10 @@ packages:
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.23.1':
resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm]
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.2':
@ -522,10 +519,10 @@ packages:
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.23.1':
resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [ia32]
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.2':
@ -534,10 +531,10 @@ packages:
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.23.1':
resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [loong64]
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.2':
@ -546,10 +543,10 @@ packages:
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.23.1':
resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [mips64el]
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.2':
@ -558,10 +555,10 @@ packages:
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.23.1':
resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [ppc64]
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.2':
@ -570,10 +567,10 @@ packages:
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.23.1':
resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [riscv64]
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.2':
@ -582,10 +579,10 @@ packages:
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.23.1':
resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [s390x]
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.2':
@ -594,10 +591,10 @@ packages:
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.23.1':
resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.2':
@ -606,16 +603,22 @@ packages:
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.23.1':
resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.24.2':
@ -624,11 +627,11 @@ packages:
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.23.1':
resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.2':
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
@ -636,10 +639,10 @@ packages:
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.23.1':
resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.2':
@ -648,11 +651,17 @@ packages:
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.23.1':
resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
@ -660,11 +669,11 @@ packages:
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.23.1':
resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
@ -672,10 +681,10 @@ packages:
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.23.1':
resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [ia32]
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.2':
@ -684,10 +693,10 @@ packages:
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.23.1':
resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [x64]
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.2':
@ -696,6 +705,12 @@ packages:
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@formatjs/ecma402-abstract@1.18.0':
resolution: {integrity: sha512-PEVLoa3zBevWSCZzPIM/lvPCi8P5l4G+NXQMc/CjEiaCWgyHieUoo0nM7Bs0n/NbuQ6JpXEolivQ9pKSBHaDlA==}
@ -2713,13 +2728,13 @@ packages:
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
esbuild@0.23.1:
resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
@ -3727,8 +3742,8 @@ packages:
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tsx@4.19.2:
resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
@ -4236,153 +4251,159 @@ snapshots:
'@emotion/memoize@0.7.4':
optional: true
'@esbuild/aix-ppc64@0.23.1':
optional: true
'@esbuild/aix-ppc64@0.24.2':
optional: true
'@esbuild/android-arm64@0.23.1':
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.24.2':
optional: true
'@esbuild/android-arm@0.23.1':
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.24.2':
optional: true
'@esbuild/android-x64@0.23.1':
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.24.2':
optional: true
'@esbuild/darwin-arm64@0.23.1':
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.24.2':
optional: true
'@esbuild/darwin-x64@0.23.1':
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.24.2':
optional: true
'@esbuild/freebsd-arm64@0.23.1':
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.24.2':
optional: true
'@esbuild/freebsd-x64@0.23.1':
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.24.2':
optional: true
'@esbuild/linux-arm64@0.23.1':
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.24.2':
optional: true
'@esbuild/linux-arm@0.23.1':
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.24.2':
optional: true
'@esbuild/linux-ia32@0.23.1':
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.24.2':
optional: true
'@esbuild/linux-loong64@0.23.1':
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.24.2':
optional: true
'@esbuild/linux-mips64el@0.23.1':
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.24.2':
optional: true
'@esbuild/linux-ppc64@0.23.1':
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.24.2':
optional: true
'@esbuild/linux-riscv64@0.23.1':
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.24.2':
optional: true
'@esbuild/linux-s390x@0.23.1':
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.24.2':
optional: true
'@esbuild/linux-x64@0.23.1':
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.24.2':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.24.2':
optional: true
'@esbuild/netbsd-x64@0.23.1':
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.24.2':
optional: true
'@esbuild/openbsd-arm64@0.23.1':
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
'@esbuild/openbsd-x64@0.23.1':
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.24.2':
optional: true
'@esbuild/sunos-x64@0.23.1':
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.24.2':
optional: true
'@esbuild/win32-arm64@0.23.1':
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.24.2':
optional: true
'@esbuild/win32-ia32@0.23.1':
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.24.2':
optional: true
'@esbuild/win32-x64@0.23.1':
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.24.2':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@formatjs/ecma402-abstract@1.18.0':
dependencies:
'@formatjs/intl-localematcher': 0.5.2
@ -6738,12 +6759,12 @@ snapshots:
dependencies:
'@tanstack/virtual-file-routes': 1.97.8
prettier: 3.4.2
tsx: 4.19.2
tsx: 4.21.0
zod: 3.24.1
optionalDependencies:
'@tanstack/react-router': 1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/router-plugin@1.97.22(@tanstack/react-router@1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2))':
'@tanstack/router-plugin@1.97.22(@tanstack/react-router@1.97.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.26.7
'@babel/generator': 7.26.5
@ -6767,7 +6788,7 @@ snapshots:
unplugin: 2.1.2
zod: 3.24.1
optionalDependencies:
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0)
transitivePeerDependencies:
- '@tanstack/react-router'
- supports-color
@ -6923,14 +6944,14 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2))':
'@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.26.7
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7)
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0)
transitivePeerDependencies:
- supports-color
@ -7210,33 +7231,6 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
esbuild@0.23.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.23.1
'@esbuild/android-arm': 0.23.1
'@esbuild/android-arm64': 0.23.1
'@esbuild/android-x64': 0.23.1
'@esbuild/darwin-arm64': 0.23.1
'@esbuild/darwin-x64': 0.23.1
'@esbuild/freebsd-arm64': 0.23.1
'@esbuild/freebsd-x64': 0.23.1
'@esbuild/linux-arm': 0.23.1
'@esbuild/linux-arm64': 0.23.1
'@esbuild/linux-ia32': 0.23.1
'@esbuild/linux-loong64': 0.23.1
'@esbuild/linux-mips64el': 0.23.1
'@esbuild/linux-ppc64': 0.23.1
'@esbuild/linux-riscv64': 0.23.1
'@esbuild/linux-s390x': 0.23.1
'@esbuild/linux-x64': 0.23.1
'@esbuild/netbsd-x64': 0.23.1
'@esbuild/openbsd-arm64': 0.23.1
'@esbuild/openbsd-x64': 0.23.1
'@esbuild/sunos-x64': 0.23.1
'@esbuild/win32-arm64': 0.23.1
'@esbuild/win32-ia32': 0.23.1
'@esbuild/win32-x64': 0.23.1
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
@ -7265,6 +7259,35 @@ snapshots:
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
escalade@3.1.1: {}
escalade@3.2.0: {}
@ -8480,9 +8503,9 @@ snapshots:
tslib@2.6.2: {}
tsx@4.19.2:
tsx@4.21.0:
dependencies:
esbuild: 0.23.1
esbuild: 0.27.4
get-tsconfig: 4.10.0
optionalDependencies:
fsevents: 2.3.3
@ -8613,18 +8636,18 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-svgr@4.3.0(rollup@4.32.0)(typescript@5.9.3)(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)):
vite-plugin-svgr@4.3.0(rollup@4.32.0)(typescript@5.9.3)(vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0)):
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.32.0)
'@svgr/core': 8.1.0(typescript@5.9.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)
vite: 6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2):
vite@6.0.11(@types/node@20.9.0)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.21.0):
dependencies:
esbuild: 0.24.2
postcss: 8.5.1
@ -8634,7 +8657,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.29.1
tsx: 4.19.2
tsx: 4.21.0
webpack-virtual-modules@0.6.2: {}

View file

@ -0,0 +1,293 @@
#!/usr/bin/env node
/** biome-ignore-all lint/suspicious/noConsole: <> */
/**
* Homebrew Cask Release Script for CompressO
*
* This script generates Homebrew cask files for both architectures
*
* Output: Generates Homebrew cask files in ./homebrew directory
*/
import * as crypto from 'node:crypto'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Types
interface Config {
version: string
appName: string
author: string
repo: string
arm64DmgPath: string
x64DmgPath: string
homebrewOutputDir: string
homebrewCaskFile: string
homebrewTemplateFile: string
}
interface Checksums {
arm64: string
x64: string
}
// ANSI color codes
const colors = {
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[1;33m',
reset: '\x1b[0m',
}
function log(message: string): void {
console.log(message)
}
function logGreen(message: string): void {
log(`${colors.green}${message}${colors.reset}`)
}
function logRed(message: string): void {
log(`${colors.red}${message}${colors.reset}`)
}
function logYellow(message: string): void {
log(`${colors.yellow}${message}${colors.reset}`)
}
/**
* Get the version from package.json
*/
function getVersion(): string {
const packageJsonPath = path.join(__dirname, '..', 'package.json')
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
return packageJson.version
}
/**
* Initialize configuration
*/
function initConfig(version: string): Config {
const appName = 'CompressO'
const author = 'codeforreal1'
const repo = 'compressO'
const homebrewOutputDir = path.join(__dirname, '..', 'homebrew')
return {
version,
appName,
author,
repo,
arm64DmgPath: path.join(
__dirname,
'..',
'src-tauri',
'target',
'aarch64-apple-darwin',
'release',
'bundle',
'dmg',
`${appName}_${version}_aarch64.dmg`,
),
x64DmgPath: path.join(
__dirname,
'..',
'src-tauri',
'target',
'x86_64-apple-darwin',
'release',
'bundle',
'dmg',
`${appName}_${version}_x64.dmg`,
),
homebrewOutputDir,
homebrewCaskFile: path.join(homebrewOutputDir, 'compresso.rb'),
homebrewTemplateFile: path.join(homebrewOutputDir, 'compresso.rb.template'),
}
}
/**
* Check if a file exists
*/
function fileExists(filePath: string): boolean {
return fs.existsSync(filePath)
}
/**
* Calculate SHA256 checksum of a file
*/
function calculateSha256(filePath: string): string {
const hash = crypto.createHash('sha256')
const fileBuffer = fs.readFileSync(filePath)
hash.update(fileBuffer as any)
return hash.digest('hex')
}
/**
* Read and parse the template file
*/
function readTemplate(templatePath: string): string {
if (!fileExists(templatePath)) {
throw new Error(`Template file not found at ${templatePath}`)
}
return fs.readFileSync(templatePath, 'utf-8')
}
/**
* Replace placeholders in template
*/
function replacePlaceholders(
template: string,
version: string,
arm64Sha256: string,
x64Sha256: string,
): string {
let content = template
content = content.replace(/\{\{VERSION\}\}/g, version)
content = content.replace(/\{\{ARM64_SHA256\}\}/g, arm64Sha256)
content = content.replace(/\{\{X64_SHA256\}\}/g, x64Sha256)
return content
}
/**
* Write content to a file
*/
function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8')
}
/**
* Ensure directory exists
*/
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
/**
* Generate checksums file content
*/
function generateChecksumsContent(
version: string,
checksums: Checksums,
): string {
return `CompressO v${version} Checksums
================================
ARM64 (Apple Silicon):
File: CompressO_${version}_aarch64.dmg
SHA256: ${checksums.arm64}
Intel (x86_64):
File: CompressO_${version}_x64.dmg
SHA256: ${checksums.x64}
`
}
/**
* Main function
*/
function main(): void {
logGreen('=== Homebrew Cask Release Script ===')
const version = getVersion()
logYellow(`Version: ${version}`)
log('')
const config = initConfig(version)
// Check if DMG files exist
logGreen('Checking for DMG files...')
if (!fileExists(config.arm64DmgPath)) {
logRed(`Error: ARM64 DMG not found at ${config.arm64DmgPath}`)
log(
'Please build the ARM64 version first using: npm run tauri:build -- --target aarch64-apple-darwin',
)
process.exit(1)
}
if (!fileExists(config.x64DmgPath)) {
logRed(`Error: x64 DMG not found at ${config.x64DmgPath}`)
log(
'Please build the x64 version first using: npm run tauri:build -- --target x86_64-apple-darwin',
)
process.exit(1)
}
logGreen('✓ ARM64 DMG found')
logGreen('✓ x64 DMG found')
log('')
// Calculate SHA256 checksums
logGreen('Calculating SHA256 checksums...')
const arm64Sha256 = calculateSha256(config.arm64DmgPath)
const x64Sha256 = calculateSha256(config.x64DmgPath)
logYellow(`ARM64 SHA256: ${arm64Sha256}`)
logYellow(`x64 SHA256: ${x64Sha256}`)
log('')
// Create Homebrew cask directories
ensureDir(config.homebrewOutputDir)
const casksDir = path.join(config.homebrewOutputDir, 'casks')
ensureDir(casksDir)
// Read template
const template = readTemplate(config.homebrewTemplateFile)
// Generate cask content
logGreen('Generating Homebrew cask file from template...')
const caskContent = replacePlaceholders(
template,
version,
arm64Sha256,
x64Sha256,
)
// Write main cask file
writeFile(config.homebrewCaskFile, caskContent)
logGreen(`✓ Main cask file generated: ${config.homebrewCaskFile}`)
// Write versioned backup
const versionedCaskFile = path.join(casksDir, `compresso-${version}.rb`)
const versionedCaskContent = caskContent.replace(
'cask "compresso"',
`cask "compresso@${version}"`,
)
writeFile(versionedCaskFile, versionedCaskContent)
logGreen(`✓ Versioned backup created: ${versionedCaskFile}`)
log('')
// Generate checksums file
const checksumsFile = path.join(config.homebrewOutputDir, 'checksums.txt')
const checksumsContent = generateChecksumsContent(version, {
arm64: arm64Sha256,
x64: x64Sha256,
})
writeFile(checksumsFile, checksumsContent)
logGreen(`✓ Checksum file generated: ${checksumsFile}`)
log('')
// Display the cask file content
logGreen('=== Generated Cask File ===')
log(caskContent)
log('')
}
try {
main()
} catch (error) {
if (error instanceof Error) {
logRed(`Error: ${error.message}`)
process.exit(1)
} else {
logRed('Unknown error occurred')
process.exit(1)
}
}

74
src-tauri/Cargo.lock generated
View file

@ -443,6 +443,17 @@ dependencies = [
"syn_derive",
]
[[package]]
name = "brotli"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor 4.0.3",
]
[[package]]
name = "brotli"
version = "8.0.2"
@ -451,7 +462,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
"brotli-decompressor 5.0.0",
]
[[package]]
name = "brotli-decompressor"
version = "4.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
@ -686,7 +707,7 @@ dependencies = [
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width",
"unicode-width 0.1.14",
"vec_map",
]
@ -753,7 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -780,6 +801,7 @@ dependencies = [
"cocoa",
"crossbeam-channel",
"dbus",
"exiftool-rs",
"image 0.25.9",
"img-parts",
"infer 0.15.0",
@ -1436,6 +1458,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exiftool-rs"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a972fd52a9c2ea042975eaa3ace0d8e2bdfbb85f435f2eb413d89fff73e63b23"
dependencies = [
"brotli 7.0.0",
"byteorder",
"encoding_rs",
"flate2",
"thiserror 2.0.17",
"unicode-width 0.2.2",
"winres",
"xml-rs",
]
[[package]]
name = "exr"
version = "1.74.0"
@ -5650,7 +5688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1fe64c74cc40f90848281a90058a6db931eb400b60205840e09801ee30f190"
dependencies = [
"base64 0.22.1",
"brotli",
"brotli 8.0.2",
"ico",
"json-patch",
"plist",
@ -5908,7 +5946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
dependencies = [
"anyhow",
"brotli",
"brotli 8.0.2",
"cargo_metadata",
"ctor",
"dunce",
@ -5987,7 +6025,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -6181,6 +6219,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.8.2"
@ -6520,6 +6567,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -7703,6 +7756,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"

View file

@ -46,6 +46,7 @@ usvg = "0.45.1"
svgcleaner = "0.9.5"
vtracer = "0.6.5"
resvg = "0.44.0"
exiftool-rs = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9"

View file

@ -449,3 +449,62 @@ pub enum MediaCompressionResult {
pub struct MediaBatchCompressionResult {
pub results: HashMap<String, MediaCompressionResult>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ImageBasicInfo {
pub filename: String,
pub format: String,
pub format_long_name: String,
pub mime_type: String,
pub size: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ImageDimensions {
pub width: u32,
pub height: u32,
pub aspect_ratio: String,
pub orientation: Option<u32>,
pub dpi: Option<(u32, u32)>, // (x, y)
pub megapixels: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ImageColorInfo {
pub color_type: String,
pub bit_depth: u8,
pub has_alpha: bool,
pub color_space: Option<String>,
pub pixel_format: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ExifTag {
pub key: String,
pub value: String,
pub category: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ExifInfo {
pub tags: Vec<ExifTag>,
pub make: Option<String>,
pub model: Option<String>,
pub software: Option<String>,
pub date_time_original: Option<String>,
pub date_time_digitized: Option<String>,
pub copyright: Option<String>,
pub artist: Option<String>,
pub gps_coordinates: Option<(f64, f64)>, // (latitude, longitude)
pub lens_model: Option<String>,
pub iso: Option<u32>,
pub exposure_time: Option<String>,
pub f_number: Option<String>,
pub focal_length: Option<String>,
pub flash: Option<String>,
}

View file

@ -959,7 +959,7 @@ impl ImageCompressor {
Ok(())
}
pub fn copy_image_metadata(
fn copy_image_metadata(
&self,
container: ImageContainer,
src: &str,

View file

@ -0,0 +1,296 @@
use crate::core::domain::{ExifInfo, ExifTag, ImageBasicInfo, ImageColorInfo, ImageDimensions};
use exiftool_rs::ExifTool;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
pub struct ImageInfo;
impl ImageInfo {
pub fn get_basic_info(path: &str) -> Result<ImageBasicInfo, String> {
let path_obj = Path::new(path);
let file_name = path_obj
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown")
.to_string();
let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?;
let size = metadata.len();
let format = Self::detect_format(path)?;
let (format_long_name, mime_type) = match format.as_str() {
"JPEG" => ("Joint Photographic Experts Group", "image/jpeg".to_string()),
"PNG" => ("Portable Network Graphics", "image/png".to_string()),
"GIF" => ("Graphics Interchange Format", "image/gif".to_string()),
"WEBP" => ("WebP Image", "image/webp".to_string()),
"BMP" => ("Bitmap Image", "image/bmp".to_string()),
"TIFF" => ("Tagged Image File Format", "image/tiff".to_string()),
"AVIF" => ("AV1 Image File Format", "image/avif".to_string()),
"ICO" => ("Icon Image", "image/x-icon".to_string()),
"PNM" => ("Portable Any Map", "image/x-portable-anymap".to_string()),
"TGA" => ("Truevision Graphics Adapter", "image/x-tga".to_string()),
_ => ("Unknown Format", "application/octet-stream".to_string()),
};
Ok(ImageBasicInfo {
filename: file_name,
format,
format_long_name: format_long_name.to_string(),
mime_type,
size,
})
}
pub fn get_dimensions(path: &str) -> Result<ImageDimensions, String> {
let file = File::open(path).map_err(|e| e.to_string())?;
let reader = BufReader::new(file);
let image = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|e| e.to_string())?
.decode()
.map_err(|e| e.to_string())?;
let width = image.width();
let height = image.height();
let gcd = Self::gcd(width, height);
let aspect_ratio = format!("{}/{}", width / gcd, height / gcd);
let megapixels = (width as f64 * height as f64) / 1_000_000.0;
let (orientation, dpi) = Self::get_orientation_and_dpi(path)?;
Ok(ImageDimensions {
width,
height,
aspect_ratio,
orientation,
dpi,
megapixels,
})
}
pub fn get_color_info(path: &str) -> Result<ImageColorInfo, String> {
let file = File::open(path).map_err(|e| e.to_string())?;
let reader = BufReader::new(file);
let image = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|e| e.to_string())?
.decode()
.map_err(|e| e.to_string())?;
let color = image.color();
let (color_type, has_alpha) = match color {
image::ColorType::L8 => ("Grayscale (8-bit)".to_string(), false),
image::ColorType::La8 => ("Grayscale with Alpha (8-bit)".to_string(), true),
image::ColorType::La16 => ("Grayscale (16-bit)".to_string(), false),
image::ColorType::Rgb8 => ("RGB (8-bit)".to_string(), false),
image::ColorType::Rgba8 => ("RGBA (8-bit)".to_string(), true),
image::ColorType::Rgb16 => ("RGB (16-bit)".to_string(), false),
image::ColorType::Rgba16 => ("RGBA (16-bit)".to_string(), true),
_ => ("Unknown".to_string(), false),
};
let bit_depth = Self::get_bit_depth(color);
Ok(ImageColorInfo {
color_type,
bit_depth,
has_alpha,
color_space: None,
pixel_format: format!("{:?}", color),
})
}
pub fn get_exif_info(path: &str) -> Result<ExifInfo, String> {
let et = ExifTool::new();
let tags_result = et
.extract_info(path)
.map_err(|e| format!("Failed to read EXIF data: {}", e))?;
let mut tags = Vec::new();
let mut make = None;
let mut model = None;
let mut software = None;
let mut date_time_original = None;
let mut date_time_digitized = None;
let mut copyright = None;
let mut artist = None;
let mut gps_coordinates = None;
let mut lens_model = None;
let mut iso = None;
let mut exposure_time = None;
let mut f_number = None;
let mut focal_length = None;
let mut flash = None;
for tag in &tags_result {
let tag_name = tag.name.to_string();
let tag_value = tag.print_value.to_string();
let category = "EXIF".to_string();
tags.push(ExifTag {
key: tag_name.clone(),
value: tag_value.clone(),
category,
});
match tag_name.as_str() {
"Make" => make = Some(tag_value),
"Model" => model = Some(tag_value),
"Software" => software = Some(tag_value),
"DateTimeOriginal" => date_time_original = Some(tag_value),
"DateTimeDigitized" | "CreateDate" => date_time_digitized = Some(tag_value),
"Copyright" => copyright = Some(tag_value),
"Artist" => artist = Some(tag_value),
"LensModel" => lens_model = Some(tag_value),
"ISO" => {
if let Ok(iso_val) = tag_value.parse::<u32>() {
iso = Some(iso_val);
}
}
"ExposureTime" => exposure_time = Some(tag_value),
"FNumber" => f_number = Some(tag_value),
"FocalLength" => focal_length = Some(tag_value),
"Flash" => flash = Some(tag_value),
"GPSLatitude" | "GPSLongitude" => {
// GPS coordinates handling will be done separately
}
_ => {}
}
}
let gps_lat = tags_result
.iter()
.find(|t| t.name == "GPSLatitude")
.and_then(|t| Self::parse_gps_coordinate(&t.print_value));
let gps_lon = tags_result
.iter()
.find(|t| t.name == "GPSLongitude")
.and_then(|t| Self::parse_gps_coordinate(&t.print_value));
let gps_lat_ref = tags_result
.iter()
.find(|t| t.name == "GPSLatitudeRef")
.map(|t| t.print_value.to_string());
let gps_lon_ref = tags_result
.iter()
.find(|t| t.name == "GPSLongitudeRef")
.map(|t| t.print_value.to_string());
if let (Some(lat), Some(lon), Some(lat_ref), Some(lon_ref)) =
(gps_lat, gps_lon, gps_lat_ref, gps_lon_ref)
{
let final_lat = if lat_ref == "S" { -lat } else { lat };
let final_lon = if lon_ref == "W" { -lon } else { lon };
gps_coordinates = Some((final_lat, final_lon));
}
Ok(ExifInfo {
tags,
make,
model,
software,
date_time_original,
date_time_digitized,
copyright,
artist,
gps_coordinates,
lens_model,
iso,
exposure_time,
f_number,
focal_length,
flash,
})
}
fn detect_format(path: &str) -> Result<String, String> {
let file = File::open(path).map_err(|e| e.to_string())?;
let reader = BufReader::new(file);
let format = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|e| e.to_string())?
.format();
Ok(format!("{:?}", format).to_string())
}
fn gcd(a: u32, b: u32) -> u32 {
if b == 0 {
a
} else {
Self::gcd(b, a % b)
}
}
fn get_orientation_and_dpi(path: &str) -> Result<(Option<u32>, Option<(u32, u32)>), String> {
let et = ExifTool::new();
let tags_result = et
.extract_info(path)
.map_err(|e| format!("Failed to read EXIF data: {}", e))?;
let mut orientation = None;
let mut x_density = None;
let mut y_density = None;
for tag in &tags_result {
match tag.name.as_str() {
"Orientation" => {
if let Ok(val) = tag.print_value.parse::<u32>() {
orientation = Some(val);
}
}
"XResolution" => {
if let Ok(val) = tag.print_value.parse::<u32>() {
x_density = Some(val);
}
}
"YResolution" => {
if let Ok(val) = tag.print_value.parse::<u32>() {
y_density = Some(val);
}
}
_ => {}
}
}
let dpi = match (x_density, y_density) {
(Some(x), Some(y)) => Some((x, y)),
_ => None,
};
Ok((orientation, dpi))
}
fn parse_gps_coordinate(coord_str: &str) -> Option<f64> {
let parts: Vec<&str> = coord_str.split_whitespace().collect();
if parts.len() >= 4 {
let degrees = parts[0].parse::<f64>().ok()?;
let minutes = parts
.iter()
.find(|p| p.ends_with('\''))
.and_then(|p| p.trim_end_matches('\'').parse::<f64>().ok())?;
let seconds = parts
.iter()
.find(|p| p.ends_with('"'))
.and_then(|p| p.trim_end_matches('"').parse::<f64>().ok())?;
Some(degrees + minutes / 60.0 + seconds / 3600.0)
} else {
coord_str.parse::<f64>().ok()
}
}
fn get_bit_depth(color: image::ColorType) -> u8 {
match color {
image::ColorType::L8
| image::ColorType::Rgb8
| image::ColorType::La8
| image::ColorType::Rgba8 => 8,
image::ColorType::La16 | image::ColorType::Rgb16 | image::ColorType::Rgba16 => 16,
_ => 8,
}
}
}

View file

@ -2,4 +2,5 @@ pub mod domain;
pub mod ffmpeg;
pub mod ffprobe;
pub mod image;
pub mod image_info;
pub mod media_process;

View file

@ -24,7 +24,10 @@ use tauri_commands::{
copy_file_to_clipboard, delete_cache, delete_file, get_file_metadata, get_image_dimension,
get_svg_dimension, move_file, read_files_from_clipboard, read_files_from_paths,
},
image::{compress_images_batch, convert_svg_to_png},
image::{
compress_images_batch, convert_svg_to_png, get_exif_info, get_image_basic_info,
get_image_color_info, get_image_dimensions,
},
media::compress_media_batch,
updater::{check_update, download_and_install_update},
};
@ -272,7 +275,11 @@ async fn main() {
clear_dock_badge,
check_update,
download_and_install_update,
convert_svg_to_png
convert_svg_to_png,
get_image_basic_info,
get_image_dimensions,
get_image_color_info,
get_exif_info
])
.build(tauri::generate_context!())
.expect("error while running tauri application")

View file

@ -1,8 +1,12 @@
use std::path::PathBuf;
use crate::core::{
domain::{ImageBatchCompressionResult, ImageCompressionConfig},
domain::{
ExifInfo, ImageBasicInfo, ImageBatchCompressionResult, ImageColorInfo,
ImageCompressionConfig, ImageDimensions,
},
image,
image_info::ImageInfo,
};
use crate::sys::fs::delete_stale_files;
@ -58,3 +62,23 @@ pub async fn convert_svg_to_png(
)?;
Ok(output_path_str.to_string())
}
#[tauri::command]
pub async fn get_image_basic_info(image_path: &str) -> Result<ImageBasicInfo, String> {
ImageInfo::get_basic_info(image_path)
}
#[tauri::command]
pub async fn get_image_dimensions(image_path: &str) -> Result<ImageDimensions, String> {
ImageInfo::get_dimensions(image_path)
}
#[tauri::command]
pub async fn get_image_color_info(image_path: &str) -> Result<ImageColorInfo, String> {
ImageInfo::get_color_info(image_path)
}
#[tauri::command]
pub async fn get_exif_info(image_path: &str) -> Result<ExifInfo, String> {
ImageInfo::get_exif_info(image_path)
}

View file

@ -248,3 +248,19 @@ body > * {
.advanced-cropper {
@apply !bg-white1 dark:!bg-black1;
}
.select-text > p,
.select-text > span,
.select-text > code,
.select-text > pre,
.select-text > h1,
.select-text > h2,
.select-text > h3,
.select-text > h4,
.select-text > h5,
.select-text > h6 {
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
user-select: text !important;
}

View file

@ -55,6 +55,7 @@ function Root() {
const handleMediaSelection = React.useCallback(
async (path: string | string[]) => {
if (appProxy.state.isCompressing) return
const videoExtensions = Object.keys(extensions.video)
const imageExtensions = Object.keys(extensions.image)

View file

@ -0,0 +1,562 @@
import { Tab } from '@heroui/react'
import { motion } from 'framer-motion'
import { startCase } from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import Code from '@/components/Code'
import Divider from '@/components/Divider'
import ScrollShadow from '@/components/ScrollShadow'
import Spinner from '@/components/Spinner'
import Tabs from '@/components/Tabs'
import {
getExifInfo,
getImageBasicInfo,
getImageColorInfo,
getImageDimensions,
} from '@/tauri/commands/image'
import {
ExifInfo,
ImageBasicInfo,
ImageColorInfo,
ImageDimensions,
} from '@/types/compression'
import { formatBytes } from '@/utils/fs'
import { appProxy } from '../-state'
type ImageInfoProps = {
mediaIndex: number
onClose?: () => void
}
const TABS = {
container: {
id: 'container',
title: 'Container',
},
color: {
id: 'color',
title: 'Color',
},
exif: {
id: 'exif',
title: 'EXIF',
},
} as const
function ImageInfo({ mediaIndex, onClose }: ImageInfoProps) {
if (mediaIndex < 0) return null
const {
state: { media },
} = useSnapshot(appProxy)
const image =
media.length && mediaIndex >= 0 && media[mediaIndex].type === 'image'
? media[mediaIndex]
: null
const { pathRaw: imagePathRaw, imageInfoRaw } = image ?? {}
if (!image) return null
const [tab, setTab] = useState<keyof typeof TABS>('container')
const [loading, setLoading] = useState(false)
const fetchTabData = useCallback(
async (tabKey: keyof typeof TABS) => {
const image = appProxy.state.media[mediaIndex]
if (!imagePathRaw || !image || image.type !== 'image') {
return
}
if (!image.imageInfoRaw) {
image.imageInfoRaw = {}
}
setLoading(true)
try {
switch (tabKey) {
case 'container': {
if (!image?.imageInfoRaw?.basicInfo) {
const data = await getImageBasicInfo(imagePathRaw)
if (data) {
image.imageInfoRaw.basicInfo = data
}
}
if (!image?.imageInfoRaw?.dimensions) {
const data = await getImageDimensions(imagePathRaw)
if (data) {
image.imageInfoRaw.dimensions = data
}
}
break
}
case 'color': {
if (!image?.imageInfoRaw?.colorInfo) {
const data = await getImageColorInfo(imagePathRaw)
if (data) {
image.imageInfoRaw.colorInfo = data
}
}
break
}
case 'exif': {
if (!image?.imageInfoRaw?.exifInfo) {
const data = await getExifInfo(imagePathRaw)
if (data) {
image.imageInfoRaw.exifInfo = data
}
}
break
}
}
} catch {
//
} finally {
setLoading(false)
}
},
[imagePathRaw, mediaIndex],
)
useEffect(() => {
fetchTabData(tab)
}, [tab, fetchTabData])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
return (
<section className="w-full h-full bg-white1 dark:bg-black1 p-6">
<div className="w-full flex justify-center">
<Tabs
aria-label="Image Information"
size="sm"
selectedKey={tab}
onSelectionChange={(t) => setTab(t as keyof typeof TABS)}
classNames={{
tabContent: 'text-[11px]',
tab: 'h-6',
}}
>
{Object.values(TABS).map((t) => (
<Tab key={t.id} value={t.id} title={t.title} />
))}
</Tabs>
</div>
<ScrollShadow
className="mt-6 overflow-y-auto max-h-[calc(100vh-200px)] pb-10"
hideScrollBar
>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="sm" />
</div>
) : null}
{!loading &&
tab === 'container' &&
(imageInfoRaw?.basicInfo || imageInfoRaw?.dimensions) ? (
<BasicInfoDisplay
info={imageInfoRaw.basicInfo as any}
dimensions={imageInfoRaw.dimensions as any}
imagePathRaw={imagePathRaw}
/>
) : null}
{!loading && tab === 'color' && imageInfoRaw?.colorInfo ? (
<ColorInfoDisplay info={imageInfoRaw.colorInfo as any} />
) : null}
{!loading && tab === 'exif' && imageInfoRaw?.exifInfo ? (
<ExifDisplay exif={imageInfoRaw.exifInfo as any} />
) : null}
</ScrollShadow>
</section>
)
}
function BasicInfoDisplay({
info,
dimensions,
imagePathRaw,
}: {
info: ImageBasicInfo
dimensions?: ImageDimensions
imagePathRaw?: string | null
}) {
return (
<div className="space-y-4">
{imagePathRaw ? (
<>
<InfoItem
label="Full Path"
value={
<Code size="sm" className="text-xs max-w-[100%] truncate">
{imagePathRaw}
</Code>
}
/>
<Divider className="my-1" />
</>
) : null}
{info.filename ? (
<>
<InfoItem label="File Name" value={info.filename} />
<Divider className="my-1" />
</>
) : null}
{info.format ? (
<>
<InfoItem label="Format" value={info.format} />
<Divider className="my-1" />
</>
) : null}
{info.mimeType ? (
<>
<InfoItem label="MIME Type" value={info.mimeType} />
<Divider className="my-1" />
</>
) : null}
{info.size > 0 ? (
<>
<InfoItem label="Size" value={formatBytes(info.size)} />
<Divider className="my-1" />
</>
) : null}
{dimensions && (
<>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<InfoItem label="Width" value={`${dimensions.width}px`} />
<Divider className="my-3" />
</div>
<div>
<InfoItem label="Height" value={`${dimensions.height}px`} />
<Divider className="my-3" />
</div>
</div>
{dimensions.aspectRatio ? (
<>
<InfoItem label="Aspect Ratio" value={dimensions.aspectRatio} />
<Divider className="my-3" />
</>
) : null}
{dimensions.orientation ? (
<>
<InfoItem
label="Orientation"
value={`${dimensions.orientation}°`}
/>
<Divider className="my-3" />
</>
) : null}
{dimensions.dpi ? (
<>
<InfoItem
label="DPI"
value={`${dimensions.dpi[0]} × ${dimensions.dpi[1]}`}
/>
<Divider className="my-3" />
</>
) : null}
<InfoItem
label="Megapixels"
value={`${dimensions.megapixels.toFixed(2)} MP`}
/>
<Divider className="my-3" />
</>
)}
</div>
)
}
function ColorInfoDisplay({ info }: { info: ImageColorInfo }) {
return (
<div className="space-y-4">
{info.colorType ? (
<>
<InfoItem label="Color Type" value={info.colorType} />
<Divider className="my-3" />
</>
) : null}
{info.bitDepth ? (
<>
<InfoItem label="Bit Depth" value={`${info.bitDepth}-bit`} />
<Divider className="my-3" />
</>
) : null}
<InfoItem label="Alpha Channel" value={info.hasAlpha ? 'Yes' : 'No'} />
<Divider className="my-3" />
{info.colorSpace ? (
<>
<InfoItem label="Color Space" value={info.colorSpace} />
<Divider className="my-3" />
</>
) : null}
{info.pixelFormat ? (
<>
<InfoItem label="Pixel Format" value={info.pixelFormat} />
<Divider className="my-3" />
</>
) : null}
</div>
)
}
function ExifDisplay({ exif }: { exif: ExifInfo }) {
const hasCameraInfo =
exif.make || exif.model || exif.lensModel || exif.software
const hasShootingInfo =
exif.iso ||
exif.exposureTime ||
exif.fNumber ||
exif.focalLength ||
exif.flash
const hasDateInfo = exif.dateTimeOriginal || exif.dateTimeDigitized
const hasCopyrightInfo = exif.copyright || exif.artist
const hasGpsInfo = exif.gpsCoordinates
if (
!hasCameraInfo &&
!hasShootingInfo &&
!hasDateInfo &&
!hasCopyrightInfo &&
!hasGpsInfo &&
(!exif.tags || exif.tags.length === 0)
) {
return (
<p className="text-center text-zinc-500 py-8 select-text">
No EXIF data found
</p>
)
}
return (
<div className="space-y-6">
{hasCameraInfo ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
Camera Information
</h3>
{exif.make ? (
<>
<InfoItem label="Make" value={exif.make} />
<Divider className="my-3" />
</>
) : null}
{exif.model ? (
<>
<InfoItem label="Model" value={exif.model} />
<Divider className="my-3" />
</>
) : null}
{exif.lensModel ? (
<>
<InfoItem label="Lens Model" value={exif.lensModel} />
<Divider className="my-3" />
</>
) : null}
{exif.software ? (
<>
<InfoItem label="Software" value={exif.software} />
<Divider className="my-3" />
</>
) : null}
</motion.div>
) : null}
{hasShootingInfo ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
Shooting Information
</h3>
{exif.iso ? (
<>
<InfoItem label="ISO" value={`ISO ${exif.iso}`} />
<Divider className="my-3" />
</>
) : null}
{exif.exposureTime ? (
<>
<InfoItem label="Exposure Time" value={exif.exposureTime} />
<Divider className="my-3" />
</>
) : null}
{exif.fNumber ? (
<>
<InfoItem label="Aperture" value={`f/${exif.fNumber}`} />
<Divider className="my-3" />
</>
) : null}
{exif.focalLength ? (
<>
<InfoItem label="Focal Length" value={exif.focalLength} />
<Divider className="my-3" />
</>
) : null}
{exif.flash ? (
<>
<InfoItem label="Flash" value={exif.flash} />
<Divider className="my-3" />
</>
) : null}
</motion.div>
) : null}
{hasDateInfo ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
Date Information
</h3>
{exif.dateTimeOriginal ? (
<>
<InfoItem label="Date Taken" value={exif.dateTimeOriginal} />
<Divider className="my-3" />
</>
) : null}
{exif.dateTimeDigitized ? (
<>
<InfoItem label="Date Digitized" value={exif.dateTimeDigitized} />
<Divider className="my-3" />
</>
) : null}
</motion.div>
) : null}
{hasCopyrightInfo ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
Copyright Information
</h3>
{exif.artist ? (
<>
<InfoItem label="Artist" value={exif.artist} />
<Divider className="my-3" />
</>
) : null}
{exif.copyright ? (
<>
<InfoItem label="Copyright" value={exif.copyright} />
<Divider className="my-3" />
</>
) : null}
</motion.div>
) : null}
{hasGpsInfo ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
GPS Information
</h3>
{exif.gpsCoordinates ? (
<>
<InfoItem
label="Coordinates"
value={`${exif.gpsCoordinates[0].toFixed(6)}°, ${exif.gpsCoordinates[1].toFixed(6)}°`}
/>
<Divider className="my-3" />
</>
) : null}
</motion.div>
) : null}
{exif.tags && exif.tags.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary select-text">
All EXIF Tags
</h3>
<div className="space-y-2">
{exif.tags.map((tag, index) => (
<div key={index}>
<InfoItem label={startCase(tag.key)} value={tag.value} />
<Divider className="my-2" />
</div>
))}
</div>
</motion.div>
) : null}
</div>
)
}
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between select-text">
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
{label}:
</span>
<span className="text-[13px] text-zinc-800 dark:text-zinc-200 ml-2 max-w-[75%] text-end">
{value || 'N/A'}
</span>
</div>
)
}
export default ImageInfo

View file

@ -13,6 +13,7 @@ import { Ripple } from '@/ui/Patterns/Ripple'
import { slideUpTransition, zoomInTransition } from '@/utils/animation'
import { formatDuration } from '@/utils/string'
import { cn } from '@/utils/tailwind'
import ImageInfo from './ImageInfo'
import MediaOutputCompareSlider from './MediaOutputCompareSlider'
import MediaThumbnail from './MediaThumbnail'
import styles from './styles.module.css'
@ -223,12 +224,21 @@ function PreviewSingleMedia({ mediaIndex }: PreviewSingleMediaProps) {
{...slideUpTransition}
>
<div className="2xl:max-w-[50vw] mx-auto">
<VideoInfo
mediaIndex={mediaIndex}
onClose={() => {
appProxy.state.showMediaInfo = false
}}
/>
{mediaFile?.type === 'video' ? (
<VideoInfo
mediaIndex={mediaIndex}
onClose={() => {
appProxy.state.showMediaInfo = false
}}
/>
) : mediaFile?.type === 'image' ? (
<ImageInfo
mediaIndex={mediaIndex}
onClose={() => {
appProxy.state.showMediaInfo = false
}}
/>
) : null}
</div>
<div className="absolute top-4 right-4">
<Button

View file

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { readFilesFromClipboard } from '@/tauri/commands/fs'
import { appProxy } from '../-state'
type ReadFilesFromClipboardProps = {
onFiles: (files: string[]) => void
@ -8,16 +9,19 @@ type ReadFilesFromClipboardProps = {
function ReadFilesFromClipboard({ onFiles }: ReadFilesFromClipboardProps) {
useEffect(() => {
function handleReadFilesFromClipboard() {
readFilesFromClipboard()
.then((files: string[]) => {
if (Array.isArray(files)) {
onFiles?.(files)
}
})
.catch(() => {
//ignore
})
async function handleReadFilesFromClipboard() {
// Large blob files from clipboard can take some time, so we need a blocking UI
appProxy.state.isLoadingMediaFiles = true
try {
const files = await readFilesFromClipboard()
if (Array.isArray(files)) {
onFiles?.(files)
}
} catch {
// ignore
} finally {
appProxy.state.isLoadingMediaFiles = false
}
}
window.addEventListener('paste', handleReadFilesFromClipboard)

View file

@ -65,6 +65,10 @@ const TABS = {
id: 'chapters',
title: 'Chapters',
},
metadata: {
id: 'metadata',
title: 'Metadata',
},
} as const
function VideoInfo({ mediaIndex, onClose }: VideoInfoProps) {
@ -144,9 +148,18 @@ function VideoInfo({ mediaIndex, onClose }: VideoInfoProps) {
}
break
}
case 'metadata': {
if (!video?.videoInfoRaw?.containerInfo) {
const data = await getContainerInfo(videoPathRaw)
if (data) {
video.videoInfoRaw.containerInfo = data
}
}
break
}
}
} catch {
toast.error('Failed to load video information')
//
} finally {
setLoading(false)
}
@ -221,6 +234,10 @@ function VideoInfo({ mediaIndex, onClose }: VideoInfoProps) {
videoPath={videoPathRaw}
/>
) : null}
{!loading && tab === 'metadata' && videoInfoRaw?.containerInfo ? (
<MetadataDisplay info={videoInfoRaw?.containerInfo as any} />
) : null}
</ScrollShadow>
</section>
)
@ -228,7 +245,7 @@ function VideoInfo({ mediaIndex, onClose }: VideoInfoProps) {
function ContainerInfoDisplay({ info }: { info: ContainerInfo }) {
return (
<div className="space-y-4">
<div className="space-y-4 select-text">
{info.filename ? (
<>
<InfoItem
@ -290,32 +307,41 @@ function ContainerInfoDisplay({ info }: { info: ContainerInfo }) {
<Divider className="my-1" />
</>
) : null}
</div>
)
}
{info.tags && info.tags.length > 0 ? (
<div>
<InfoItem label="Metadata Tags" value=" " />
<div className="mt-2 space-y-2 mx-4">
{info.tags.map(([key, value]) => (
<div key={key}>
<p className="font-bold text-zinc-600 dark:text-zinc-400 text-[13px]">
{startCase(key)}:
</p>{' '}
<span className="text-zinc-800 dark:text-zinc-200 allow-user-selection text-[13px]">
{value ?? 'N/A'}
</span>
<Divider className="mt-2" />
</div>
))}
function MetadataDisplay({ info }: { info: ContainerInfo }) {
if (!info.tags || info.tags.length === 0) {
return (
<p className="text-center text-zinc-500 py-8 select-text">
No metadata found
</p>
)
}
return (
<div className="space-y-4 select-text">
<div className="mt-2 space-y-2">
{info.tags.map(([key, value]) => (
<div key={key} className="select-text">
<p className="font-bold text-zinc-600 dark:text-zinc-400 text-[13px]">
{startCase(key)}:
</p>{' '}
<span className="text-zinc-800 dark:text-zinc-200 text-[13px]">
{value ?? 'N/A'}
</span>
<Divider className="mt-2" />
</div>
</div>
) : null}
))}
</div>
</div>
)
}
function VideoStreamsDisplay({ streams }: { streams: VideoStream[] }) {
return (
<div className="space-y-6">
<div className="space-y-6 select-text">
{streams.map((stream, index) => (
<motion.div
key={index}
@ -324,7 +350,7 @@ function VideoStreamsDisplay({ streams }: { streams: VideoStream[] }) {
transition={{ delay: index * 0.05 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary">
<h3 className="text-lg font-semibold text-primary select-text">
Video Stream {streams.length > 1 ? `${index + 1}` : ''}
</h3>
@ -502,12 +528,14 @@ function VideoStreamsDisplay({ streams }: { streams: VideoStream[] }) {
function AudioStreamsDisplay({ streams }: { streams: AudioStream[] }) {
if (streams.length === 0) {
return (
<p className="text-center text-zinc-500 py-8">No audio streams found</p>
<p className="text-center text-zinc-500 py-8 select-text">
No audio streams found
</p>
)
}
return (
<div className="space-y-6">
<div className="space-y-6 select-text">
{streams.map((stream, index) => (
<motion.div
key={index}
@ -516,7 +544,7 @@ function AudioStreamsDisplay({ streams }: { streams: AudioStream[] }) {
transition={{ delay: index * 0.05 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-primary">
<h3 className="text-lg font-semibold text-primary select-text">
Audio Stream {streams.length > 1 ? `${index + 1}` : ''}
</h3>
@ -595,7 +623,7 @@ function AudioStreamsDisplay({ streams }: { streams: AudioStream[] }) {
<InfoItem label="Metadata Tags" value=" " />
<div className="mt-2 space-y-2 mx-4">
{stream.tags.map(([key, value]) => (
<div key={key}>
<div key={key} className="select-text">
<span className="font-medium text-zinc-600 dark:text-zinc-400 text-[13px]">
{startCase(key)}:
</span>{' '}
@ -648,7 +676,7 @@ function SubtitleStreamsDisplay({
if (streams.length === 0) {
return (
<p className="text-center text-zinc-500 py-8">
<p className="text-center text-zinc-500 py-8 select-text">
No subtitle streams found
</p>
)
@ -690,14 +718,14 @@ function SubtitleStreamsDisplay({
toast.success(`Subtitle extracted and saved as ${format.toUpperCase()}.`)
} catch {
toast.error('Failed to extract subtitle.')
//
} finally {
setDownloadingIndex(null)
}
}
return (
<div className="space-y-6">
<div className="space-y-6 select-text">
{streams.map((stream, index) => {
const isExtractable = isSubtitleExtractable(stream.codec)
const formatConfig = SUBTITLE_FORMATS[selectedFormat]
@ -710,7 +738,7 @@ function SubtitleStreamsDisplay({
className="space-y-4"
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-primary">
<h3 className="text-lg font-semibold text-primary select-text">
Subtitle Stream {index + 1}
</h3>
<ButtonGroup variant="flat" size="sm">
@ -767,7 +795,7 @@ function SubtitleStreamsDisplay({
value={`${stream.codec} (${stream.codecLongName})`}
/>
{!isExtractable && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="text-xs text-amber-600 dark:text-amber-400 select-text">
This subtitle format ({stream.codec}) cannot be converted to
SRT. It is likely an image-based format (e.g., Blu-ray PGS or
DVD VobSub).
@ -795,7 +823,7 @@ function SubtitleStreamsDisplay({
stream.disposition.comment ||
stream.disposition.karaoke ||
stream.disposition.lyrics ? (
<div>
<div className="select-text">
<InfoItem label="Disposition" value=" " />
<div className="mt-2 space-y-1 ml-4">
{stream.disposition.default ? (
@ -840,11 +868,15 @@ function SubtitleStreamsDisplay({
function ChaptersDisplay({ chapters }: { chapters: Chapter[] }) {
if (chapters.length === 0) {
return <p className="text-center text-zinc-500 py-8">No chapters found</p>
return (
<p className="text-center text-zinc-500 py-8 select-text">
No chapters found
</p>
)
}
return (
<div className="space-y-4">
<div className="space-y-4 select-text">
{chapters.map((chapter, index) => (
<motion.div
key={index}
@ -852,7 +884,7 @@ function ChaptersDisplay({ chapters }: { chapters: Chapter[] }) {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between select-text">
<h3 className="text-lg font-semibold text-primary">
Chapter {index + 1} {chapter.id ? `(#${chapter.id})` : ''}
</h3>
@ -891,11 +923,11 @@ function ChaptersDisplay({ chapters }: { chapters: Chapter[] }) {
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between !select-text !before:select-text">
<div className="flex items-baseline justify-between select-text">
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
{label}:
</span>
<span className="text-[13px] text-zinc-800 dark:text-zinc-200 ml-2 allow-user-selection max-w-[75%] text-end">
<span className="text-[13px] text-zinc-800 dark:text-zinc-200 ml-2 max-w-[75%] text-end">
{value || 'N/A'}
</span>
</div>

View file

@ -41,6 +41,7 @@ export function copyFileToClipboard(filePath: string) {
export function readFilesFromClipboard() {
return core.invoke<string[]>('read_files_from_clipboard')
}
export function readFilesFromPaths(paths: string[]) {
return core.invoke<string[]>('read_files_from_paths', { paths })
}

View file

@ -1,8 +1,33 @@
import { core } from '@tauri-apps/api'
import {
ExifInfo,
ImageBasicInfo,
ImageColorInfo,
ImageDimensions,
} from '@/types/compression'
export async function convertSvgToPng(imagePath: string, imageId: string) {
return await core.invoke<string>('convert_svg_to_png', {
imagePath,
imageId,
})
}
export function getImageBasicInfo(imagePath: string): Promise<ImageBasicInfo> {
return core.invoke('get_image_basic_info', { imagePath })
}
export function getImageDimensions(
imagePath: string,
): Promise<ImageDimensions> {
return core.invoke('get_image_dimensions', { imagePath })
}
export function getImageColorInfo(imagePath: string): Promise<ImageColorInfo> {
return core.invoke('get_image_color_info', { imagePath })
}
export function getExifInfo(imagePath: string): Promise<ExifInfo> {
return core.invoke('get_exif_info', { imagePath })
}

View file

@ -7,7 +7,11 @@ import {
Chapter,
ContainerInfo,
compressionPresets,
ExifInfo,
extensions,
ImageBasicInfo,
ImageColorInfo,
ImageDimensions,
MediaTransformHistory,
MediaTransforms,
SubtitleStream,
@ -180,6 +184,12 @@ export type Image = {
config: ImageConfig
isConfigDirty?: boolean
dimensions?: { width: number; height: number }
imageInfoRaw?: {
basicInfo?: ImageBasicInfo
dimensions?: ImageDimensions
colorInfo?: ImageColorInfo
exifInfo?: ExifInfo
}
}
export type App = {

View file

@ -289,3 +289,52 @@ export type BatchMediaIndividualCompressionResult = {
export type MediaBatchCompressionResult = {
results: Record<string, MediaCompressionResult>
}
export type ImageBasicInfo = {
filename: string
format: string
formatLongName: string
mimeType: string
size: number
}
export type ImageDimensions = {
width: number
height: number
aspectRatio: string
orientation: number | null
dpi: [number, number] | null
megapixels: number
}
export type ImageColorInfo = {
colorType: string
bitDepth: number
hasAlpha: boolean
colorSpace: string | null
pixelFormat: string
}
export type ExifTag = {
key: string
value: string
category: string
}
export type ExifInfo = {
tags: ExifTag[]
make: string | null
model: string | null
software: string | null
dateTimeOriginal: string | null
dateTimeDigitized: string | null
copyright: string | null
artist: string | null
gpsCoordinates: [number, number] | null
lensModel: string | null
iso: number | null
exposureTime: string | null
fNumber: string | null
focalLength: string | null
flash: string | null
}

View file

@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"include": ["**/*.ts", "**/*.tsx", "**/*.mts"],
"exclude": ["node_modules", "website", "dist"]
}