diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f3eb33a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2024 Sadman Sakib + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9a50726..2dc6888 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,90 @@ -# picseal +# Picseal -模仿小米照片风格,生成莱卡水印照片。同时支持佳能、尼康、苹果、华为、小米、DJI 等水印。可自动识别,也可自定义处理。 +生成类似小米照片风格的莱卡水印照片。支持佳能、尼康、苹果、华为、小米、DJI 等设备的水印生成,可自动识别,也可自定义处理。 -在线试用: -- [zhiweio.github.io/picseal](https://zhiweio.github.io/picseal/) +[English](./README_en.md) 中文 + +## 在线演示 + +在线试用地址: - [picseal.vercel.app](https://picseal.vercel.app) +- [picseal.zhiweio.me](https://picseal.zhiweio.me) +- [zhiweio.github.io/picseal](https://zhiweio.github.io/picseal/) -![](./public/screenshot.png) +![应用截图](./public/screenshot.png) -| Deploy with Vercel | -| :-------------------------------------: | -| [![][deploy-button-image]][deploy-link] | +## 部署方法 + +### 使用 Vercel 部署 -#### Deploy locally +| 一键部署到 Vercel | +| :-----------------------------------: | +| [![][deploy-button-image]][deploy-link] | -1. Clone the repository +### 本地部署 +1. **克隆项目代码**: ```bash - git clone https://github.com/zhiwei/picseal + git clone https://github.com/zhiweio/picseal ``` -2. Install dependencies - - ```bash - # Install Rustup (compiler) - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - # Install wasm-pack - curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y - ``` +2. **安装依赖**: + ```bash + # 安装 Rustup(编译器) + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -3. Build and run + # 安装 wasm-pack + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y + ``` +3. **构建并运行**: ```bash npm install npm run build npm run preview ``` -#### Deploy with GitHub Pages - -Configure `base` in `vite.config.ts` to your GitHub Pages URL (e.g. `https://.github.io//`). - -```javascript -import wasm from 'vite-plugin-wasm' - -export default defineConfig({ - plugins: [ - react(), - wasm(), - topLevelAwait(), - visualizer({ open: true }), - ], - server: { - port: 3000, - }, - build: { - outDir: 'dist', - target: 'esnext', - }, - optimizeDeps: { - exclude: ['picseal'], - }, - base: 'https://zhiweio.github.io/picseal/', -}) -``` - -Build and run +### 使用 GitHub Pages 部署 + +1. 修改 `vite.config.ts` 中的 `base` 配置为你的 GitHub Pages URL(例如:`https://.github.io//`): + ```javascript + import wasm from 'vite-plugin-wasm' + + export default defineConfig({ + plugins: [ + react(), + wasm(), + topLevelAwait(), + visualizer({ open: true }), + ], + server: { + port: 3000, + }, + build: { + outDir: 'dist', + target: 'esnext', + }, + optimizeDeps: { + exclude: ['picseal'], + }, + base: 'https://zhiweio.github.io/picseal/', + }) + ``` +2. **构建并部署**: ```bash npm install npm run pages ``` - +## 作者 + +- [@Wang Zhiwei](https://github.com/zhiweio) + +## 开源协议 + +[MIT](https://choosealicense.com/licenses/mit/) + [deploy-button-image]: https://vercel.com/button [deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzhiweio%2Fpicseal&project-name=picseal&repository-name=picseal diff --git a/README_en.md b/README_en.md new file mode 100644 index 0000000..2dbfdaa --- /dev/null +++ b/README_en.md @@ -0,0 +1,90 @@ +# Picseal + +Generate Leica-style watermark photos inspired by Xiaomi's photo style. Picseal supports automatic or custom watermark generation for Canon, Nikon, Apple, Huawei, Xiaomi, DJI, and more. + +English [中文](./README.md) + +## Online Demo + +Try it online: +- [picseal.vercel.app](https://picseal.vercel.app) +- [picseal.zhiweio.me](https://picseal.zhiweio.me) +- [zhiweio.github.io/picseal](https://zhiweio.github.io/picseal/) + +![App Screenshot](./public/screenshot.png) + +## Deployment + +### Deploy with Vercel + +| Deploy with Vercel | +| :-------------------------------------: | +| [![][deploy-button-image]][deploy-link] | + +### Deploy Locally + +1. **Clone the repository**: + ```bash + git clone https://github.com/zhiweio/picseal + ``` + +2. **Install dependencies**: + ```bash + # Install Rustup (compiler) + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + + # Install wasm-pack + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y + ``` + +3. **Build and run**: + ```bash + npm install + npm run build + npm run preview + ``` + +### Deploy with GitHub Pages + +1. Configure the `base` in `vite.config.ts` to match your GitHub Pages URL (e.g., `https://.github.io//`): + ```javascript + import wasm from 'vite-plugin-wasm' + + export default defineConfig({ + plugins: [ + react(), + wasm(), + topLevelAwait(), + visualizer({ open: true }), + ], + server: { + port: 3000, + }, + build: { + outDir: 'dist', + target: 'esnext', + }, + optimizeDeps: { + exclude: ['picseal'], + }, + base: 'https://zhiweio.github.io/picseal/', + }) + ``` + +2. **Build and deploy**: + ```bash + npm install + npm run pages + ``` + +## Authors + +- [@Wang Zhiwei](https://github.com/zhiweio) + +## License + +[MIT](https://choosealicense.com/licenses/mit/) + + +[deploy-button-image]: https://vercel.com/button +[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzhiweio%2Fpicseal&project-name=picseal&repository-name=picseal diff --git a/package.json b/package.json index dccbb5f..50a46cf 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "preview": "vite preview", - "pages": "npm run build && gh-pages -d dist --dest . --repo `git remote get-url origin`" + "pages": "npm run build && cp vercel.json dist && gh-pages -d dist --dest . --repo `git remote get-url origin`" }, "dependencies": { "@ant-design/cssinjs": "^1.22.0", @@ -41,8 +41,11 @@ "eslint-plugin-react-refresh": "^0.4.14", "gh-pages": "^6.2.0", "rollup-plugin-visualizer": "^5.12.0", + "sharp": "^0.33.5", + "svgo": "^3.3.2", "typescript": "^5.7.2", "vite": "^5.4.11", + "vite-plugin-image-optimizer": "^1.1.8", "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0" } diff --git a/public/screenshot.png b/public/screenshot.png index ce82dc9..661d4ee 100644 Binary files a/public/screenshot.png and b/public/screenshot.png differ diff --git a/src/App.tsx b/src/App.tsx index b5e6ea9..2f01282 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,71 +1,31 @@ -import type { RcFile } from 'antd/es/upload' -import type { ExifData, FormValue } from './types' +import type { ExifParamsForm } from './types' import { createFromIconfontCN, DownloadOutlined, PlusOutlined } from '@ant-design/icons' -import { Button, Divider, Flex, Form, Input, message, Select, Slider, Space, Typography, Upload } from 'antd' -import domtoimage from 'dom-to-image' -import moment from 'moment' - +import { Button, Divider, Flex, Form, Input, Select, Slider, Space, Typography, Upload } from 'antd' import { useEffect, useRef, useState } from 'react' - +import { useImageHandlers } from './hooks/useImageHandlers' + +import { BrandsList, getBrandUrl } from './utils/BrandUtils' +import { + DefaultPictureExif, + getRandomImage, + parseExifData, +} from './utils/ImageUtils' import init, { get_exif } from './wasm/gen_brand_photo_pictrue' + import './styles/App.css' -const IconFont = createFromIconfontCN({ +export const IconFont = createFromIconfontCN({ scriptUrl: [ '//at.alicdn.com/t/c/font_4757469_u2nszgglli.js', // icon-apple, icon-jianeng, icon-DJI, icon-fushi, icon-huawei1, icon-laika, icon-icon-xiaomiguishu, icon-nikon, icon-sony ], }) -const brandList = [ - 'Apple', - 'Canon', - 'Dji', - 'Fujifilm', - 'Huawei', - 'Leica', - 'Xiaomi', - 'Nikon Corporation', - 'Sony', - '未收录', -] - -const initialFormValue = { - model: 'XIAOMI 13 ULTRA', - date: moment().format('YYYY.MM.DD HH:mm:ss'), - gps: `41°12'47"N 124°00'16"W`, - device: '75mm f/1.8 1/33s ISO800', - brand: 'leica', - brand_url: './brand/leica.svg', - scale: 0.8, - fontSize: 'normal', - fontWeight: 'bold', - fontFamily: 'misans', -} - -const exhibitionImages = [ - './exhibition/apple.jpg', - './exhibition/canon.jpg', - './exhibition/dji.jpg', - './exhibition/fujifilm.jpg', - './exhibition/huawei.jpg', - './exhibition/leica.jpg', - './exhibition/xiaomi.jpg', - './exhibition/nikon.jpg', - './exhibition/sony.jpg', -] - -// 在组件初始化时随机选择一张照片 -function randomImage() { - const randomIndex = Math.floor(Math.random() * exhibitionImages.length) - return exhibitionImages[randomIndex] -} - function App() { + const formRef = useRef() + const { imgRef, imgUrl, setImgUrl, formValue, setFormValue, handleAdd, handleDownload, handleFormChange, handleFontSizeChange, handleFontWeightChange, handleFontFamilyChange, handleScaleChange, handleExhibitionClick } = useImageHandlers(formRef, DefaultPictureExif) const [wasmLoaded, setWasmLoaded] = useState(false) - const [formValue, setFormValue] = useState(initialFormValue) - const [imgUrl, setImgUrl] = useState(randomImage()) - const formRef = useRef() + const formValueRef = useRef(DefaultPictureExif) useEffect(() => { const loadWasm = async () => { @@ -78,247 +38,47 @@ function App() { // 在组件加载时随机选择一张照片并解析其 EXIF 数据 useEffect(() => { const loadImageAndParseExif = async () => { - const randomImg = randomImage() - setImgUrl(randomImg) - const response = await fetch(randomImg) - const blob = await response.blob() - const arrayBuffer = await blob.arrayBuffer() - const exifData = get_exif(new Uint8Array(arrayBuffer)) - const parsedExif = parseExifData(exifData) - const updatedFormValue = { - ...formValue, - ...parsedExif, - brand_url: getBrandUrl(parsedExif.brand), - } - formRef.current.setFieldsValue(updatedFormValue) - setFormValue(updatedFormValue) - } - loadImageAndParseExif() - }, [wasmLoaded]) // 依赖于 wasmLoaded - - if (!wasmLoaded) { - return
Loading WASM...
- } - - // 格式化 GPS 数据 - const formatGPS = (gps: string | undefined, gpsRef: string | undefined): string => { - if (!gps) - return '' - const [degrees, minutes, seconds, dir] = gps - .match(/(\d+\.?\d*)|([NSWE]$)/gim) - .map(item => (!Number.isNaN(Number(item)) ? `${~~item}`.padStart(2, '0') : item)) - if (gpsRef) - return `${degrees}°${minutes}'${seconds}"${gpsRef}` - else if ( - dir - ) return `${degrees}°${minutes}'${seconds}"${dir}` - else - return `${degrees}°${minutes}'${seconds}"` - } - - // 格式化品牌 - const formatBrand = (make: string | undefined): string => { - const brand = (make || '').toLowerCase() - for (const b of brandList.map(b => b.toLowerCase())) { - if (brand.includes(b)) { - return b - } - } - return brand - } - - // 格式化曝光时间 - const formatExposureTime = (exposureTime: string | undefined): string => { - if (!exposureTime) - return '' - const [numerator, denominator] = exposureTime.split('/').filter(Boolean).map(item => Math.floor(Number(item))) - return [numerator, denominator].join('/') - } - - // 解析 EXIF 数据 - const parseExifData = (data: ExifData[]): Partial => { - const exif = { - GPSLatitude: '', // 纬度 - GPSLatitudeRef: '', // 纬度方向 - GPSLongitude: '', // 经度 - GPSLongitudeRef: '', // 经度方向 - FocalLengthIn35mmFilm: '', // 焦距 - FocalLength: '', // 焦距 - FNumber: '', // 光圈 - ExposureTime: '', // 曝光时间 - PhotographicSensitivity: '', // ISO - Model: '', // 设备型号 - Make: '', // 设备品牌 - DateTimeOriginal: '', // 拍摄时间 - } - const exifValues = new Map(data.map(item => [item.tag, item.value])) - const exifValuesWithUnit = new Map(data.map(item => [item.tag, item.value_with_unit])) - - exif.GPSLatitude = exifValues.get('GPSLatitude') || '' - exif.GPSLatitudeRef = exifValues.get('GPSLatitudeRef') || '' - exif.GPSLongitude = exifValues.get('GPSLongitude') || '' - exif.GPSLongitudeRef = exifValues.get('GPSLongitudeRef') || '' - exif.FocalLengthIn35mmFilm = exifValuesWithUnit.get('FocalLengthIn35mmFilm') || '' - exif.FocalLength = exifValuesWithUnit.get('FocalLength') || '' - exif.FNumber = exifValuesWithUnit.get('FNumber') || '' - exif.ExposureTime = exifValues.get('ExposureTime') || '' - exif.PhotographicSensitivity = exifValues.get('PhotographicSensitivity') || '' - exif.Model = exifValues.get('Model') || '' - exif.Make = exifValues.get('Make') || '' - exif.DateTimeOriginal = exifValues.get('DateTimeOriginal') || '' - - const gps = `${formatGPS(exif.GPSLatitude, exif.GPSLatitudeRef)} ${formatGPS(exif.GPSLongitude, exif.GPSLongitudeRef)}` - const device = [ - `${(exif.FocalLengthIn35mmFilm || exif.FocalLength).replace(/\s+/g, '')}`, - exif.FNumber?.split('/')?.map((n, i) => (i ? (+n).toFixed(1) : n)).join('/'), - exif.ExposureTime ? `${formatExposureTime(exif.ExposureTime)}s` : '', - exif.PhotographicSensitivity ? `ISO${exif.PhotographicSensitivity}` : '', - ] - .filter(Boolean) - .join(' ') - return { - model: exif.Model || 'Unknown Model', - date: exif.DateTimeOriginal || moment().format('YYYY.MM.DD HH:mm:ss'), - gps, - device, - brand: `${formatBrand(exif.Make)}`, - } - } - - // 获取品牌图标 URL - const getBrandUrl = (brand: string): string => - `./brand/${brand === '未收录' ? 'unknow.svg' : `${brand}.svg`}` - - // 处理文件上传 - const handleAdd = (file: RcFile): false => { - const reader = new FileReader() - reader.onloadend = async (e) => { - try { - const exifData = get_exif(new Uint8Array(e.target.result)) + const randomImg = getRandomImage() + const img = new Image() + img.src = randomImg + + img.onload = async () => { + const response = await fetch(randomImg) + const blob = await response.blob() + const arrayBuffer = await blob.arrayBuffer() + const exifData = get_exif(new Uint8Array(arrayBuffer)) const parsedExif = parseExifData(exifData) + + setImgUrl(randomImg) + if (imgRef.current) { + imgRef.current.classList.add('loaded') + } const updatedFormValue = { - ...formValue, + ...formValueRef.current, ...parsedExif, brand_url: getBrandUrl(parsedExif.brand), } - console.log('original EXIF data: ', exifData) - console.log('parsed EXIF data: ', parsedExif) formRef.current.setFieldsValue(updatedFormValue) setFormValue(updatedFormValue) - setImgUrl(URL.createObjectURL(new Blob([file], { type: file.type }))) - } - catch (error) { - console.error('Error parsing EXIF data:', error) - message.error('无法识别照片特定数据,请换一张照片') + formValueRef.current = updatedFormValue } } - reader.readAsArrayBuffer(file) - return false - } - - // 导出图片 - const handleDownload = (): void => { - const previewDom = document.getElementById('preview') - const zoomRatio = 4 - - domtoimage - .toJpeg(previewDom, { - quality: 1.0, - width: previewDom.clientWidth * zoomRatio, - height: previewDom.clientHeight * zoomRatio, - style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, - }) - .then((dataUrl) => { - const link = document.createElement('a') - link.download = `${Date.now()}.jpg` - link.href = dataUrl - document.body.appendChild(link) - link.click() - link.remove() - }) - .catch((err) => { - console.error('Export Error:', err) - message.error('导出失败,请重试') - }) - } - - // 处理表单更新 - const handleFormChange = (_: any, values: FormValue): void => { - setFormValue({ - ...values, - brand_url: getBrandUrl(values.brand), - }) - } - - const handleScaleChange = (scale) => { - document.documentElement.style.setProperty('--banner-scale', scale) - setFormValue(prev => ({ ...prev, scale })) - } - - const handleFontSizeChange = (fontSize) => { - const sizeMap = { - small: 'var(--font-size-small)', - normal: 'var(--font-size-normal)', - large: 'var(--font-size-large)', - } - document.documentElement.style.setProperty('--current-font-size', sizeMap[fontSize]) - setFormValue(prev => ({ ...prev, fontSize })) - } - - const handleFontWeightChange = (fontWeight) => { - const weightMap = { - normal: 'var(--font-weight-normal)', - bold: 'var(--font-weight-bold)', - black: 'var(--font-weight-black)', - } - document.documentElement.style.setProperty('--current-font-weight', weightMap[fontWeight]) - setFormValue(prev => ({ ...prev, fontWeight })) - } - - const handleFontFamilyChange = (fontFamily) => { - const familyMap = { - default: 'var(--font-family-default)', - caveat: 'var(--font-family-caveat)', - misans: 'var(--font-family-misans)', - helvetica: 'var(--font-family-helvetica)', - futura: 'var(--font-family-futura)', - avenir: 'var(--font-family-avenir)', - didot: 'var(--font-family-didot)', - } - document.documentElement.style.setProperty('--current-font-family', familyMap[fontFamily]) - setFormValue(prev => ({ ...prev, fontFamily })) - } - - // 更新处理展览按钮点击的函数 - const handleExhibitionClick = async (brand: string) => { - const brandImageUrl = `./exhibition/${brand.toLowerCase()}.jpg` // 假设示例照片为 JPG 格式 - setImgUrl(brandImageUrl) - - // 读取图片文件并解析 EXIF 数据 - const response = await fetch(brandImageUrl) - const blob = await response.blob() - const arrayBuffer = await blob.arrayBuffer() - const exifData = get_exif(new Uint8Array(arrayBuffer)) - const parsedExif = parseExifData(exifData) - - const updatedFormValue = { - ...formValue, - ...parsedExif, - brand_url: getBrandUrl(parsedExif.brand), - } + loadImageAndParseExif() + }, [wasmLoaded, imgRef, setImgUrl, setFormValue]) - formRef.current.setFieldsValue(updatedFormValue) - setFormValue(updatedFormValue) + if (!wasmLoaded) { + return
Loading WASM...
} return ( <> - 水印照片生成器 + PICSEAL + 小米照片风格水印照片,支持佳能、尼康、索尼、苹果、华为、小米、大疆。
预览
- Preview + Preview
{formValue.model}
@@ -423,7 +183,7 @@ function App() {