├── .dockerignore ├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── eslint.config.js ├── index.html ├── nginx.conf ├── package.json ├── public ├── brand │ ├── apple.svg │ ├── canon.svg │ ├── dji.svg │ ├── fujifilm.svg │ ├── huawei.svg │ ├── insta360.svg │ ├── leica.svg │ ├── nikon corporation.svg │ ├── olympus.svg │ ├── panasonic.svg │ ├── ricoh.svg │ ├── sony.svg │ ├── unknow.svg │ └── xiaomi.svg ├── exhibition │ ├── apple.jpg │ ├── canon.jpg │ ├── dji.jpg │ ├── fujifilm.jpg │ ├── huawei.jpg │ ├── insta360.jpg │ ├── leica.jpg │ ├── nikon.jpg │ ├── olympus.jpg │ ├── panasonic.jpg │ ├── ricoh.jpg │ ├── sony.jpg │ └── xiaomi.jpg ├── logo.png ├── screenshot.png ├── simple.jpg └── vite.svg ├── scripts └── build-wasm.js ├── src-wasm ├── Cargo.lock ├── Cargo.toml └── src │ ├── lib.rs │ ├── main.rs │ └── utils.rs ├── src ├── App.tsx ├── components │ └── GithubCorner.tsx ├── hooks │ └── useImageHandlers.ts ├── main.tsx ├── styles │ └── App.css ├── types │ └── index.d.ts ├── utils │ ├── BrandUtils.ts │ ├── ImageUtils.ts │ └── JpegExifUtils.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .idea/ 3 | .vscode/ 4 | dist/ 5 | node_modules/ 6 | src-wasm/target/ 7 | .git/ 8 | *.md 9 | LICENSE 10 | stats.html 11 | vercel.json 12 | .dockerignore 13 | package-lock.json 14 | pnpm-lock.yaml 15 | Dockerfile 16 | .dockerignore 17 | .gitignore -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: The version to build 8 | 9 | push: 10 | tags: 11 | - '*.*' 12 | - '*.*.*' 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v3 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v2 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and Push Docker Image 29 | uses: docker/build-push-action@v4 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | push: true 34 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/picseal:latest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | package-lock.json 16 | pnpm-lock.yaml 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | !.vscode/settings.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | # WASM build output 31 | src/wasm/ 32 | src-wasm/target/ 33 | 34 | stats.html -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off", "fixable": true }, 15 | { "rule": "format/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-indent", "severity": "off", "fixable": true }, 17 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 18 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 19 | { "rule": "*-order", "severity": "off", "fixable": true }, 20 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 21 | { "rule": "*-newline", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true }, 23 | { "rule": "*semi", "severity": "off", "fixable": true } 24 | ], 25 | 26 | // Enable eslint for all supported languages 27 | "eslint.validate": [ 28 | "javascript", 29 | "javascriptreact", 30 | "typescript", 31 | "typescriptreact", 32 | "vue", 33 | "html", 34 | "markdown", 35 | "json", 36 | "json5", 37 | "jsonc", 38 | "yaml", 39 | "toml", 40 | "xml", 41 | "gql", 42 | "graphql", 43 | "astro", 44 | "css", 45 | "less", 46 | "scss", 47 | "pcss", 48 | "postcss" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 AS build 2 | 3 | RUN useradd -m picseal 4 | 5 | USER picseal 6 | 7 | ENV HOME=/home/picseal 8 | 9 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 10 | 11 | ENV PATH="$HOME/.cargo/bin:$PATH" 12 | 13 | RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y 14 | 15 | WORKDIR /app 16 | 17 | COPY . . 18 | 19 | USER root 20 | 21 | RUN chown -R picseal:picseal /app && \ 22 | chmod -R 755 /app 23 | 24 | ENV NPM_VERSION=10.9.1 25 | RUN npm cache clean --force && \ 26 | npm install -g npm@"${NPM_VERSION}" 27 | 28 | RUN npm install && \ 29 | npm run build 30 | 31 | USER picseal 32 | 33 | FROM nginx:alpine AS production 34 | 35 | COPY --from=build /app/dist /usr/share/nginx/html 36 | 37 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 38 | 39 | EXPOSE 80 40 | 41 | CMD ["nginx", "-g", "daemon off;"] 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Sadman Sakib 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Picseal 2 | 3 | 生成类似小米照片风格的莱卡水印照片。支持佳能、尼康、苹果、华为、小米、DJI 等设备的水印生成,可自动识别,也可自定义处理。 4 | 5 | ## 在线演示 6 | 7 | 在线试用地址: 8 | - [picseal.vercel.app](https://picseal.vercel.app) 9 | - [picseal.zhiweio.me](https://picseal.zhiweio.me) 10 | - [zhiweio.github.io/picseal](https://zhiweio.github.io/picseal/) 11 | 12 | ![应用截图](./public/screenshot.png) 13 | 14 | ## 技术实现 15 | 16 | ### EXIF 解析 17 | 18 | 使用了 Rust 库 `kamadak-exif` 从图片中提取得到 EXIF 信息并借助 WASM 技术嵌入前端 JavaScript 使用。 19 | 20 | ### 水印生成 21 | 22 | 通过 HTML 和 CSS 生成水印样式,能够做到动态调整实时预览。 23 | 24 | ### 图片生成 25 | 26 | 导出的图片是通过 `dom-to-image` JavaScript 库来将 DOM 转 JPEG/PNG 等格式图片,请注意这种实现生成的是和原图完全不一样的图片,可以看作屏幕截图的方式。 27 | 28 | 目前针对 JPEG 格式图片新增了复制原图 EXIF 信息嵌进导出的图片中,目前的实现方式比较简单粗暴,直接从原图二进制数据提取 EXIF 部分的数据,再同样以二进制格式进行拼接,不能确保稳定。 29 | 30 | ### 改进 31 | 32 | - [ ] 改用 Rust `little_exif` 库来实现对图片 EXIF 信息的读取和编辑。 33 | - [ ] 改用 Canvas 来实现水印,支持高度自定义。 34 | 35 | ## 部署方法 36 | 37 | ### 使用 Vercel 部署 38 | 39 | | 一键部署到 Vercel | 40 | | :-----------------------------------: | 41 | | [![][deploy-button-image]][deploy-link] | 42 | 43 | ### 本地部署 44 | 45 | 1. **克隆项目代码**: 46 | ```bash 47 | git clone https://github.com/zhiweio/picseal 48 | ``` 49 | 50 | 2. **安装依赖**: 51 | ```bash 52 | # 安装 Rustup(编译器) 53 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 54 | 55 | # 安装 wasm-pack 56 | curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y 57 | ``` 58 | 59 | 3. **构建并运行**: 60 | ```bash 61 | npm install 62 | npm run build 63 | npm run preview 64 | ``` 65 | 66 | ### 使用 GitHub Pages 部署 67 | 68 | 1. 修改 `vite.config.ts` 中的 `base` 配置为你的 GitHub Pages URL(例如:`https://.github.io//`): 69 | ```javascript 70 | import wasm from 'vite-plugin-wasm' 71 | 72 | export default defineConfig({ 73 | plugins: [ 74 | react(), 75 | wasm(), 76 | topLevelAwait(), 77 | visualizer({ open: true }), 78 | ], 79 | server: { 80 | port: 3000, 81 | }, 82 | build: { 83 | outDir: 'dist', 84 | target: 'esnext', 85 | }, 86 | optimizeDeps: { 87 | exclude: ['picseal'], 88 | }, 89 | base: 'https://zhiweio.github.io/picseal/', 90 | }) 91 | ``` 92 | 93 | 2. **构建并部署**: 94 | ```bash 95 | npm install 96 | npm run pages 97 | ``` 98 | 99 | ### 使用 Docker 部署 100 | 101 | 1. 拉取镜像 102 | ```bash 103 | docker pull zhiweio/picseal:latest 104 | ``` 105 | 106 | 2. 启动容器 107 | ```bash 108 | docker run -d -p 8080:80 picseal 109 | ``` 110 | 111 | 3. 访问 http://localhost:8080 112 | 113 | ## 作者 114 | 115 | - [@Wang Zhiwei](https://github.com/zhiweio) 116 | 117 | ## 开源协议 118 | 119 | [MIT](https://choosealicense.com/licenses/mit/) 120 | 121 | 122 | [deploy-button-image]: https://vercel.com/button 123 | [deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzhiweio%2Fpicseal&project-name=picseal&repository-name=picseal 124 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | react: true, 5 | typescript: true, 6 | rules: { 7 | // Allow console statements 8 | 'no-console': 'off', 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | 8 | location / { 9 | try_files $uri $uri/ /index.html; 10 | add_header Access-Control-Allow-Origin *; 11 | add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; 12 | add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept'; 13 | } 14 | 15 | location ~* \.(?:ico|css|js|woff|woff2|ttf|otf|eot|svg|jpg|jpeg|png|gif|webp|avif|mp4|webm|ogv|ogg|mp3|m4a|wav|flac)$ { 16 | expires 6M; 17 | access_log off; 18 | add_header Cache-Control "public, max-age=15768000, immutable"; 19 | } 20 | 21 | error_page 404 /index.html; 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "picseal", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "author": "Wang Zhiwei ", 7 | "homepage": "https://github.com/zhiwei/picseal.git", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/zhiwei/picseal.git" 11 | }, 12 | "scripts": { 13 | "build:wasm": "node scripts/build-wasm.js", 14 | "dev": "npm run build:wasm && vite", 15 | "build": "npm run build:wasm && tsc && vite build", 16 | "lint": "eslint .", 17 | "lint:fix": "eslint . --fix", 18 | "preview": "vite preview", 19 | "pages": "npm run build && cp vercel.json dist && gh-pages -d dist --dest . --repo `git remote get-url origin`" 20 | }, 21 | "dependencies": { 22 | "@ant-design/cssinjs": "^1.22.0", 23 | "@ant-design/icons": "^5.5.1", 24 | "@emotion/react": "^11.13.5", 25 | "@emotion/styled": "^11.13.5", 26 | "antd": "^5.22.2", 27 | "dom-to-image": "^2.6.0", 28 | "moment": "^2.30.1", 29 | "react": "^18.3.1", 30 | "react-dom": "^18.3.1", 31 | "wasm": "file:src/wasm" 32 | }, 33 | "devDependencies": { 34 | "@antfu/eslint-config": "^3.9.2", 35 | "@eslint-react/eslint-plugin": "^1.17.1", 36 | "@types/dom-to-image": "^2.6.7", 37 | "@types/react": "^18.3.12", 38 | "@types/react-dom": "^18.3.1", 39 | "@vitejs/plugin-react": "^4.3.3", 40 | "eslint": "^9.15.0", 41 | "eslint-plugin-react-hooks": "^5.0.0", 42 | "eslint-plugin-react-refresh": "^0.4.14", 43 | "gh-pages": "^6.2.0", 44 | "rollup-plugin-visualizer": "^5.12.0", 45 | "sharp": "^0.33.5", 46 | "svgo": "^3.3.2", 47 | "typescript": "^5.7.2", 48 | "vite": "^5.4.11", 49 | "vite-plugin-image-optimizer": "^1.1.8", 50 | "vite-plugin-pwa": "^0.21.1", 51 | "vite-plugin-top-level-await": "^1.4.4", 52 | "vite-plugin-wasm": "^3.3.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/brand/apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /public/brand/canon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/brand/dji.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/brand/fujifilm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | image/svg+xml 4 | 5 | Layer 1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /public/brand/huawei.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/brand/insta360.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Insta360 logo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/brand/leica.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 13 | 16 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/brand/nikon corporation.svg: -------------------------------------------------------------------------------- 1 | 2 | Nikon 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/brand/olympus.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /public/brand/panasonic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/brand/ricoh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/brand/sony.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | 4 | 5 | Layer 1 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/brand/unknow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/brand/xiaomi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/exhibition/apple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/apple.jpg -------------------------------------------------------------------------------- /public/exhibition/canon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/canon.jpg -------------------------------------------------------------------------------- /public/exhibition/dji.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/dji.jpg -------------------------------------------------------------------------------- /public/exhibition/fujifilm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/fujifilm.jpg -------------------------------------------------------------------------------- /public/exhibition/huawei.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/huawei.jpg -------------------------------------------------------------------------------- /public/exhibition/insta360.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/insta360.jpg -------------------------------------------------------------------------------- /public/exhibition/leica.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/leica.jpg -------------------------------------------------------------------------------- /public/exhibition/nikon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/nikon.jpg -------------------------------------------------------------------------------- /public/exhibition/olympus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/olympus.jpg -------------------------------------------------------------------------------- /public/exhibition/panasonic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/panasonic.jpg -------------------------------------------------------------------------------- /public/exhibition/ricoh.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/ricoh.jpg -------------------------------------------------------------------------------- /public/exhibition/sony.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/sony.jpg -------------------------------------------------------------------------------- /public/exhibition/xiaomi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/exhibition/xiaomi.jpg -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/logo.png -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/screenshot.png -------------------------------------------------------------------------------- /public/simple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhiweio/picseal/45a36be17eab23688650bac9e7346041562bd740/public/simple.jpg -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/build-wasm.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = path.dirname(__filename) 8 | 9 | // 构建 WASM 10 | console.log('Building WASM...') 11 | execSync('wasm-pack build src-wasm --target web --out-dir ../src/wasm', { 12 | stdio: 'inherit', 13 | }) 14 | 15 | // 确保 WASM 目录存在 16 | const wasmDir = path.join(__dirname, '../src/wasm') 17 | if (!fs.existsSync(wasmDir)) { 18 | fs.mkdirSync(wasmDir, { recursive: true }) 19 | } 20 | 21 | console.log('WASM build completed!') 22 | -------------------------------------------------------------------------------- /src-wasm/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bumpalo" 7 | version = "3.16.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "0.1.10" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "console_error_panic_hook" 25 | version = "0.1.7" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 28 | dependencies = [ 29 | "cfg-if 1.0.0", 30 | "wasm-bindgen", 31 | ] 32 | 33 | [[package]] 34 | name = "gen_brand_photo_pictrue" 35 | version = "0.0.1" 36 | dependencies = [ 37 | "console_error_panic_hook", 38 | "gloo-utils", 39 | "kamadak-exif", 40 | "serde", 41 | "serde_json", 42 | "wasm-bindgen", 43 | "wee_alloc", 44 | ] 45 | 46 | [[package]] 47 | name = "gloo-utils" 48 | version = "0.2.0" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" 51 | dependencies = [ 52 | "js-sys", 53 | "serde", 54 | "serde_json", 55 | "wasm-bindgen", 56 | "web-sys", 57 | ] 58 | 59 | [[package]] 60 | name = "itoa" 61 | version = "1.0.13" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" 64 | 65 | [[package]] 66 | name = "js-sys" 67 | version = "0.3.72" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" 70 | dependencies = [ 71 | "wasm-bindgen", 72 | ] 73 | 74 | [[package]] 75 | name = "kamadak-exif" 76 | version = "0.5.5" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" 79 | dependencies = [ 80 | "mutate_once", 81 | ] 82 | 83 | [[package]] 84 | name = "libc" 85 | version = "0.2.164" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" 88 | 89 | [[package]] 90 | name = "log" 91 | version = "0.4.22" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 94 | 95 | [[package]] 96 | name = "memchr" 97 | version = "2.7.4" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 100 | 101 | [[package]] 102 | name = "memory_units" 103 | version = "0.4.0" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" 106 | 107 | [[package]] 108 | name = "mutate_once" 109 | version = "0.1.1" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" 112 | 113 | [[package]] 114 | name = "once_cell" 115 | version = "1.20.2" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 118 | 119 | [[package]] 120 | name = "proc-macro2" 121 | version = "1.0.92" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 124 | dependencies = [ 125 | "unicode-ident", 126 | ] 127 | 128 | [[package]] 129 | name = "quote" 130 | version = "1.0.37" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 133 | dependencies = [ 134 | "proc-macro2", 135 | ] 136 | 137 | [[package]] 138 | name = "ryu" 139 | version = "1.0.18" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 142 | 143 | [[package]] 144 | name = "serde" 145 | version = "1.0.215" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" 148 | dependencies = [ 149 | "serde_derive", 150 | ] 151 | 152 | [[package]] 153 | name = "serde_derive" 154 | version = "1.0.215" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" 157 | dependencies = [ 158 | "proc-macro2", 159 | "quote", 160 | "syn", 161 | ] 162 | 163 | [[package]] 164 | name = "serde_json" 165 | version = "1.0.133" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" 168 | dependencies = [ 169 | "itoa", 170 | "memchr", 171 | "ryu", 172 | "serde", 173 | ] 174 | 175 | [[package]] 176 | name = "syn" 177 | version = "2.0.89" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" 180 | dependencies = [ 181 | "proc-macro2", 182 | "quote", 183 | "unicode-ident", 184 | ] 185 | 186 | [[package]] 187 | name = "unicode-ident" 188 | version = "1.0.14" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 191 | 192 | [[package]] 193 | name = "wasm-bindgen" 194 | version = "0.2.95" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" 197 | dependencies = [ 198 | "cfg-if 1.0.0", 199 | "once_cell", 200 | "serde", 201 | "serde_json", 202 | "wasm-bindgen-macro", 203 | ] 204 | 205 | [[package]] 206 | name = "wasm-bindgen-backend" 207 | version = "0.2.95" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" 210 | dependencies = [ 211 | "bumpalo", 212 | "log", 213 | "once_cell", 214 | "proc-macro2", 215 | "quote", 216 | "syn", 217 | "wasm-bindgen-shared", 218 | ] 219 | 220 | [[package]] 221 | name = "wasm-bindgen-macro" 222 | version = "0.2.95" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" 225 | dependencies = [ 226 | "quote", 227 | "wasm-bindgen-macro-support", 228 | ] 229 | 230 | [[package]] 231 | name = "wasm-bindgen-macro-support" 232 | version = "0.2.95" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" 235 | dependencies = [ 236 | "proc-macro2", 237 | "quote", 238 | "syn", 239 | "wasm-bindgen-backend", 240 | "wasm-bindgen-shared", 241 | ] 242 | 243 | [[package]] 244 | name = "wasm-bindgen-shared" 245 | version = "0.2.95" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" 248 | 249 | [[package]] 250 | name = "web-sys" 251 | version = "0.3.72" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" 254 | dependencies = [ 255 | "js-sys", 256 | "wasm-bindgen", 257 | ] 258 | 259 | [[package]] 260 | name = "wee_alloc" 261 | version = "0.4.5" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" 264 | dependencies = [ 265 | "cfg-if 0.1.10", 266 | "libc", 267 | "memory_units", 268 | "winapi", 269 | ] 270 | 271 | [[package]] 272 | name = "winapi" 273 | version = "0.3.9" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 276 | dependencies = [ 277 | "winapi-i686-pc-windows-gnu", 278 | "winapi-x86_64-pc-windows-gnu", 279 | ] 280 | 281 | [[package]] 282 | name = "winapi-i686-pc-windows-gnu" 283 | version = "0.4.0" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 286 | 287 | [[package]] 288 | name = "winapi-x86_64-pc-windows-gnu" 289 | version = "0.4.0" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 292 | -------------------------------------------------------------------------------- /src-wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gen_brand_photo_pictrue" 3 | version = "0.0.1" 4 | authors = [ "Wang Zhiwei" ] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = [ 9 | "cdylib", 10 | "rlib" 11 | ] 12 | 13 | [features] 14 | default = [ 15 | "console_error_panic_hook", 16 | "wee_alloc" 17 | ] 18 | 19 | [dependencies] 20 | wasm-bindgen = { version = "0.2.81", features = [ "serde-serialize" ] } 21 | gloo-utils = "0.2" 22 | console_error_panic_hook = { version = "0.1.7", optional = true } 23 | wee_alloc = { version = "0.4.5", optional = true } 24 | kamadak-exif = "0.5.4" 25 | serde = { version = "1.0.138", features = [ "derive" ] } 26 | serde_json = "1.0.133" 27 | 28 | [profile.release] 29 | opt-level = "s" 30 | 31 | [package.metadata.wasm-pack.profile.release] 32 | wasm-opt = false 33 | -------------------------------------------------------------------------------- /src-wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | use gloo_utils::format::JsValueSerdeExt; 4 | use serde::Serialize; 5 | use wasm_bindgen::prelude::*; 6 | 7 | #[cfg(feature = "wee_alloc")] 8 | #[global_allocator] 9 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 10 | 11 | #[derive(Debug, Serialize)] 12 | struct ExifData { 13 | tag: String, 14 | value: String, 15 | value_with_unit: String, 16 | } 17 | 18 | #[wasm_bindgen(start)] 19 | pub fn run() { 20 | utils::set_panic_hook(); 21 | } 22 | 23 | #[wasm_bindgen] 24 | pub fn get_exif(raw: Vec) -> JsValue { 25 | let mut exif_data: Vec = Vec::new(); 26 | let exif_reader = exif::Reader::new(); 27 | let mut bufreader = std::io::Cursor::new(raw.as_slice()); 28 | 29 | // Try to read EXIF data, fallback to empty if it fails 30 | match exif_reader.read_from_container(&mut bufreader) { 31 | Ok(exif) => { 32 | for field in exif.fields() { 33 | exif_data.push(ExifData { 34 | tag: field.tag.to_string(), 35 | value: field.display_value().to_string(), 36 | value_with_unit: field.display_value().with_unit(&exif).to_string(), 37 | }); 38 | } 39 | } 40 | Err(_) => { 41 | // Use empty EXIF data if parsing fails 42 | } 43 | } 44 | 45 | ::from_serde(&exif_data).unwrap() 46 | } 47 | -------------------------------------------------------------------------------- /src-wasm/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::{fs, io, path::Path}; 3 | 4 | struct SourceFile { 5 | files: Vec<&'static str>, 6 | dirs: Vec<&'static str>, 7 | } 8 | 9 | fn main() -> Result<(), Box> { 10 | let build_dir_path = Path::new("build"); 11 | let source = SourceFile { 12 | files: vec![ 13 | "index.html", 14 | ], 15 | dirs: vec!["src", "pubilc"], 16 | }; 17 | 18 | match fs::remove_dir_all(&build_dir_path) { _ => {} }; 19 | 20 | // 创建目标目录; 21 | fs::create_dir_all(&build_dir_path)?; 22 | 23 | // 遍历 fiLes 24 | for file in source.files.iter() { 25 | fs::copy(&file, build_dir_path.join(&file))?; 26 | } 27 | 28 | // 遍历 dirs 29 | for dir in source.dirs.iter() { 30 | copy_dir_content(Path::new(dir), &build_dir_path.join(dir))?; 31 | } 32 | 33 | println!("Successfully copied into {}", build_dir_path.display()); 34 | 35 | Ok(()) 36 | } 37 | 38 | fn copy_dir_content(dir: &Path, to_dir: &Path) -> io::Result<()> { 39 | // 创建目标目录; 40 | fs::create_dir_all(&to_dir)?; 41 | 42 | // 读取源目录,进行内容遍历 43 | for item in fs::read_dir(dir)? { 44 | // 获取 DirEntry 45 | let item_entry = item?; 46 | 47 | // 是否为目录 48 | if item_entry.file_type()?.is_dir() { 49 | copy_dir_content(&item_entry.path(), &to_dir.join(item_entry.file_name()))?; 50 | } else { 51 | fs::copy(item_entry.path(), to_dir.join(item_entry.file_name()))?; 52 | } 53 | } 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src-wasm/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn set_panic_hook() { 2 | #[cfg(feature = "console_error_panic_hook")] 3 | console_error_panic_hook::set_once(); 4 | } 5 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import type { ExifParamsForm } from './types' 2 | import { createFromIconfontCN, DownloadOutlined, PlusOutlined } from '@ant-design/icons' 3 | import { Button, Divider, Flex, Form, Input, Select, Slider, Space, Switch, Tooltip, Typography, Upload } from 'antd' 4 | import { useEffect, useRef, useState } from 'react' 5 | import { useImageHandlers } from './hooks/useImageHandlers' 6 | 7 | import { BrandsList, getBrandUrl } from './utils/BrandUtils.ts' 8 | import { 9 | DefaultPictureExif, 10 | getRandomImage, 11 | parseExifData, 12 | } from './utils/ImageUtils' 13 | import init, { get_exif } from './wasm/gen_brand_photo_pictrue' 14 | 15 | import './styles/App.css' 16 | 17 | export const IconFont = createFromIconfontCN({ 18 | scriptUrl: [ 19 | '//at.alicdn.com/t/c/font_4757469_kkotyy5658l.js', // icon-apple, icon-jianeng, icon-DJI, icon-fushi, icon-huawei1, icon-laika, icon-icon-xiaomiguishu, icon-nikon, icon-sony 20 | ], 21 | }) 22 | 23 | function App() { 24 | const formRef = useRef() 25 | const { imgRef, imgUrl, setImgUrl, formValue, setFormValue, handleAdd, handleDownload, handleFormChange, handleFontSizeChange, handleFontWeightChange, handleFontFamilyChange, handleScaleChange, handleExhibitionClick } = useImageHandlers(formRef, DefaultPictureExif) 26 | const [wasmLoaded, setWasmLoaded] = useState(false) 27 | const [exifEnable, setExifEnable] = useState(false) 28 | 29 | const formValueRef = useRef(DefaultPictureExif) 30 | 31 | useEffect(() => { 32 | const loadWasm = async () => { 33 | await init() 34 | setWasmLoaded(true) 35 | } 36 | loadWasm() 37 | }, []) 38 | 39 | // 在组件加载时随机选择一张照片并解析其 EXIF 数据 40 | useEffect(() => { 41 | const loadImageAndParseExif = async () => { 42 | const randomImg = getRandomImage() 43 | const img = new Image() 44 | img.src = randomImg 45 | 46 | img.onload = async () => { 47 | const response = await fetch(randomImg) 48 | const blob = await response.blob() 49 | const arrayBuffer = await blob.arrayBuffer() 50 | const exifData = get_exif(new Uint8Array(arrayBuffer)) 51 | const parsedExif = parseExifData(exifData) 52 | 53 | setImgUrl(randomImg) 54 | if (imgRef.current) { 55 | imgRef.current.classList.add('loaded') 56 | } 57 | const updatedFormValue = { 58 | ...formValueRef.current, 59 | ...parsedExif, 60 | brand_url: getBrandUrl(parsedExif.brand), 61 | } 62 | formRef.current.setFieldsValue(updatedFormValue) 63 | setFormValue(updatedFormValue) 64 | formValueRef.current = updatedFormValue 65 | } 66 | } 67 | loadImageAndParseExif() 68 | }, [wasmLoaded, imgRef, setImgUrl, setFormValue]) 69 | 70 | if (!wasmLoaded) { 71 | return
Loading WASM...
72 | } 73 | 74 | return ( 75 | <> 76 | PICSEAL 77 | 78 | 小米照片风格水印照片,支持佳能、尼康、索尼、苹果、华为、小米、大疆。 79 |
80 | 预览 81 |
82 | Preview 83 |
84 |
85 |
{formValue.model}
86 |
{formValue.date}
87 |
88 | Brand 89 |
90 |
91 |
92 |
93 |
{formValue.device}
94 |
{formValue.gps}
95 |
96 |
97 |
98 |
99 | 100 |
101 | 102 | 106 | 110 | 114 | 118 | 122 | 126 | 130 | 134 | 138 | 142 | 146 | 150 | 154 | 155 |
156 | 157 |
158 | 159 | { 162 | handleAdd(file) 163 | return false 164 | }} 165 | fileList={[]} 166 | > 167 | 170 | 171 | 179 | 180 | 181 |
182 | 183 |
184 |
185 | 参数 186 |
187 |
188 | 189 | 导出 EXIF 190 | 191 | setExifEnable(!exifEnable)} 194 | /> 195 | 196 | 197 |
207 | 208 | 214 | 215 | 216 | 217 | 218 | 219 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 242 | 243 | 244 | 249 | 250 | 251 | 260 | 261 |
262 |
263 |
264 | 265 | ) 266 | } 267 | 268 | export default App 269 | -------------------------------------------------------------------------------- /src/components/GithubCorner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function GithubCorner() { 4 | return ( 5 | 10 | 32 | 61 | 62 | ) 63 | } 64 | 65 | export default GithubCorner 66 | -------------------------------------------------------------------------------- /src/hooks/useImageHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { RcFile } from 'antd/es/upload' 2 | 3 | import type { ExifParamsForm } from '../types' 4 | import { message } from 'antd' 5 | import domtoimage from 'dom-to-image' 6 | import { useRef, useState } from 'react' 7 | import { getBrandUrl } from '../utils/BrandUtils' 8 | import { dataURLtoBlob, getRandomImage, parseExifData } from '../utils/ImageUtils' 9 | import { embedExifRaw, extractExifRaw } from '../utils/JpegExifUtils' 10 | import { get_exif } from '../wasm/gen_brand_photo_pictrue' 11 | 12 | export function useImageHandlers(formRef: any, initialFormValue: ExifParamsForm) { 13 | const [formValue, setFormValue] = useState(initialFormValue) 14 | const [imgUrl, setImgUrl] = useState(getRandomImage()) 15 | const imgRef = useRef(null) 16 | const [uploadImgType, setUploadImgType] = useState() 17 | const [exifBlob, setExifBlob] = useState(null) 18 | 19 | // 处理文件上传 20 | const handleAdd = (file: RcFile): false => { 21 | const reader = new FileReader() 22 | reader.onloadend = async (e) => { 23 | try { 24 | const exifData = get_exif(new Uint8Array(e.target.result)) 25 | const parsedExif = parseExifData(exifData) 26 | const updatedFormValue = { 27 | ...formValue, 28 | ...parsedExif, 29 | brand_url: getBrandUrl(parsedExif.brand), 30 | } 31 | console.log('original EXIF data: ', exifData) 32 | console.log('parsed EXIF data: ', parsedExif) 33 | formRef.current.setFieldsValue(updatedFormValue) 34 | setFormValue(updatedFormValue) 35 | setImgUrl(URL.createObjectURL(new Blob([file], { type: file.type }))) 36 | const parsedExifBlob = await extractExifRaw(new Blob([file])) 37 | setExifBlob(parsedExifBlob) 38 | setUploadImgType(file.type) 39 | } 40 | catch (error) { 41 | console.error('Error parsing EXIF data:', error) 42 | message.error('无法识别照片特定数据,请换一张照片', 300) 43 | } 44 | } 45 | reader.readAsArrayBuffer(file) 46 | return false 47 | } 48 | 49 | // 导出图片 50 | const handleDownload = async (exifEnable: boolean): Promise => { 51 | const previewDom = document.getElementById('preview') 52 | const zoomRatio = 4 53 | 54 | try { 55 | let dataUrl: string 56 | if (uploadImgType === 'image/png') { 57 | console.log('dom to png') 58 | dataUrl = await domtoimage.toPng(previewDom, { 59 | quality: 1.0, 60 | width: previewDom.clientWidth * zoomRatio, 61 | height: previewDom.clientHeight * zoomRatio, 62 | style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, 63 | }) 64 | } 65 | else { 66 | dataUrl = await domtoimage.toJpeg(previewDom, { 67 | quality: 1.0, 68 | width: previewDom.clientWidth * zoomRatio, 69 | height: previewDom.clientHeight * zoomRatio, 70 | style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, 71 | }) 72 | } 73 | 74 | const link = document.createElement('a') 75 | if (exifEnable && exifBlob) { 76 | if (uploadImgType === 'image/jpeg' || uploadImgType === 'image/jpg') { 77 | console.log('embed exif in jpg') 78 | const imgBlob = dataURLtoBlob(dataUrl) 79 | const downloadImg = await embedExifRaw(exifBlob, imgBlob) 80 | link.href = URL.createObjectURL(downloadImg) 81 | } 82 | else { 83 | console.warn('EXIF blob data can only be embedded in JPEG or JPG images.') 84 | link.href = dataUrl 85 | } 86 | } 87 | else { 88 | link.href = dataUrl 89 | } 90 | const fileExt: string = (uploadImgType || 'jpg').replace(/image\//g, '') 91 | link.download = `${Date.now()}.${fileExt}` 92 | document.body.appendChild(link) 93 | link.click() 94 | link.remove() 95 | } 96 | catch (error) { 97 | console.error('Download Error:', error) 98 | message.error('导出失败,请重试') 99 | } 100 | } 101 | 102 | // 处理表单更新 103 | const handleFormChange = (_: any, values: ExifParamsForm): void => { 104 | setFormValue({ 105 | ...values, 106 | brand_url: getBrandUrl(values.brand), 107 | }) 108 | } 109 | 110 | const handleScaleChange = (scale) => { 111 | document.documentElement.style.setProperty('--banner-scale', scale) 112 | setFormValue(prev => ({ ...prev, scale })) 113 | } 114 | 115 | const handleFontSizeChange = (fontSize) => { 116 | const sizeMap = { 117 | small: 'var(--font-size-small)', 118 | normal: 'var(--font-size-normal)', 119 | large: 'var(--font-size-large)', 120 | } 121 | document.documentElement.style.setProperty('--current-font-size', sizeMap[fontSize]) 122 | setFormValue(prev => ({ ...prev, fontSize })) 123 | } 124 | 125 | const handleFontWeightChange = (fontWeight) => { 126 | const weightMap = { 127 | normal: 'var(--font-weight-normal)', 128 | bold: 'var(--font-weight-bold)', 129 | black: 'var(--font-weight-black)', 130 | } 131 | document.documentElement.style.setProperty('--current-font-weight', weightMap[fontWeight]) 132 | setFormValue(prev => ({ ...prev, fontWeight })) 133 | } 134 | 135 | const handleFontFamilyChange = (fontFamily) => { 136 | const familyMap = { 137 | default: 'var(--font-family-default)', 138 | caveat: 'var(--font-family-caveat)', 139 | misans: 'var(--font-family-misans)', 140 | helvetica: 'var(--font-family-helvetica)', 141 | futura: 'var(--font-family-futura)', 142 | avenir: 'var(--font-family-avenir)', 143 | didot: 'var(--font-family-didot)', 144 | } 145 | document.documentElement.style.setProperty('--current-font-family', familyMap[fontFamily]) 146 | setFormValue(prev => ({ ...prev, fontFamily })) 147 | } 148 | 149 | // 更新处理展览按钮点击的函数 150 | const handleExhibitionClick = async (brand: string) => { 151 | const brandImageUrl = `./exhibition/${brand.toLowerCase()}.jpg` 152 | 153 | // Add fade class to trigger animation 154 | if (imgRef.current) { 155 | imgRef.current.classList.add('fade') 156 | } 157 | 158 | // Wait for the fade effect to complete before changing the image 159 | setTimeout(async () => { 160 | setImgUrl(brandImageUrl) 161 | 162 | // Read image file and parse EXIF data 163 | const response = await fetch(brandImageUrl) 164 | const blob = await response.blob() 165 | const arrayBuffer = await blob.arrayBuffer() 166 | const exifData = get_exif(new Uint8Array(arrayBuffer)) 167 | const parsedExif = parseExifData(exifData) 168 | 169 | const updatedFormValue = { 170 | ...formValue, 171 | ...parsedExif, 172 | brand_url: getBrandUrl(parsedExif.brand), 173 | } 174 | 175 | formRef.current.setFieldsValue(updatedFormValue) 176 | setFormValue(updatedFormValue) 177 | 178 | // Remove fade class after the new image is set 179 | if (imgRef.current) { 180 | imgRef.current.classList.remove('fade') 181 | imgRef.current.classList.add('loaded') // Ensure the loaded class is added 182 | } 183 | }, 500) // Match the duration of the CSS transition 184 | } 185 | 186 | return { 187 | imgRef, 188 | imgUrl, 189 | setImgUrl, 190 | formValue, 191 | setFormValue, 192 | handleAdd, 193 | handleDownload, 194 | handleFormChange, 195 | handleFontSizeChange, 196 | handleFontWeightChange, 197 | handleFontFamilyChange, 198 | handleScaleChange, 199 | handleExhibitionClick, 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import GithubCorner from './components/GithubCorner' 5 | 6 | const root = document.getElementById('app') 7 | 8 | if (!root) { 9 | throw new Error('Root element not found') 10 | } 11 | 12 | ReactDOM.createRoot(root).render( 13 | 14 | 15 | 16 | , 17 | ) 18 | -------------------------------------------------------------------------------- /src/styles/App.css: -------------------------------------------------------------------------------- 1 | /* 导入 Google Fonts */ 2 | @import url('https://fonts.googleapis.com/css2?family=Caveat&family=Noto+Sans+Mono&family=Noto+Serif&display=swap'); 3 | 4 | /* 导入 MiSans 字体 */ 5 | @import url('https://cdn.jsdelivr.net/npm/misans@4.0.0/lib/Normal/MiSans-Medium.min.css'); 6 | @import url('https://cdn.jsdelivr.net/npm/misans@4.0.0/lib/Normal/MiSans-Bold.min.css'); 7 | 8 | :root { 9 | --banner-scale: 0.8; /* 基准缩放比例 */ 10 | --base-padding: 20px; 11 | --base-title-size: 18px; 12 | --base-text-size: 12px; 13 | --base-height: 39px; 14 | --base-margin: 6px; 15 | --font-size-small: 0.85; 16 | --font-size-normal: 1; 17 | --font-size-large: 1.15; 18 | --current-font-size: var(--font-size-normal); 19 | --font-weight-normal: 400; 20 | --font-weight-bold: 700; 21 | --font-weight-black: 900; 22 | --current-font-weight: var(--font-weight-bold); 23 | --font-family-default: system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; 24 | --font-family-misans: "MiSans", var(--font-family-default); /* 添加 MiSans 字体 */ 25 | --font-family-caveat: 'Caveat', cursive; 26 | --font-family-helvetica: "Helvetica Neue", Helvetica, Arial, sans-serif; 27 | --font-family-futura: Futura, "Trebuchet MS", Arial, sans-serif; 28 | --font-family-avenir: Avenir, "Avenir Next", "Segoe UI", sans-serif; 29 | --font-family-didot: Didot, "Bodoni MT", "Times New Roman", serif; 30 | --current-font-family: var(--font-family-default); 31 | } 32 | 33 | #app { 34 | width: 800px; 35 | margin: 0 auto; 36 | padding: 20px 0; 37 | } 38 | 39 | .picseal-title { 40 | font-family: var(--font-family-caveat), system-ui; 41 | } 42 | 43 | .picseal-description { 44 | font-family: var(--font-family-misans), system-ui; 45 | } 46 | 47 | .preview-box { 48 | margin-bottom: 16px; 49 | } 50 | 51 | .preview { 52 | box-shadow: 7px 4px 15px #ccc; 53 | background: #fff; 54 | } 55 | 56 | .preview-picture { 57 | max-width: 100%; 58 | transition: opacity 0.6s ease-in-out, transform 0.6s ease-in-out; 59 | opacity: 0; /* 初始透明度 */ 60 | transform: scale(0.95); /* 初始缩放 */ 61 | } 62 | 63 | .preview-picture.loaded { 64 | opacity: 1; /* 加载完成后设置为不透明 */ 65 | transform: scale(1); /* 恢复到正常大小 */ 66 | } 67 | 68 | .preview-picture.fade { 69 | opacity: 0; /* 在切换时设置为透明 */ 70 | transform: scale(0.95); /* 在切换时缩小 */ 71 | } 72 | 73 | .preview-info { 74 | padding: calc(var(--base-padding) * var(--banner-scale)); 75 | display: flex; 76 | flex-direction: row; /* Keep it as row for horizontal layout */ 77 | font-family: var(--current-font-family), system-ui; 78 | align-items: center; /* Align items vertically centered */ 79 | } 80 | 81 | .preview-info-left { 82 | flex-grow: 1; 83 | width: auto; /* Allow it to size based on content */ 84 | position: relative; 85 | display: flex; 86 | flex-direction: column; /* Stack items vertically */ 87 | align-items: flex-start; 88 | justify-content: center; /* Center items vertically */ 89 | } 90 | 91 | .preview-info-split { 92 | border-left: 2px solid #ddd; 93 | height: calc(var(--base-height) * var(--banner-scale)); 94 | margin-top: calc(var(--base-margin) * var(--banner-scale)); 95 | margin-right: 12px; 96 | margin-left: 12px; 97 | } 98 | 99 | .preview-info-right { 100 | display: flex; 101 | flex-direction: column; 102 | align-items: flex-start; 103 | justify-content: center; 104 | } 105 | 106 | .preview-info-model, 107 | .preview-info-device { 108 | font-weight: var(--current-font-weight); 109 | font-size: calc(var(--base-title-size) * var(--banner-scale) * var(--current-font-size)); 110 | white-space: nowrap; /* Prevent line breaks */ 111 | } 112 | 113 | .preview-info-date, 114 | .preview-info-gps { 115 | font-size: calc(var(--base-text-size) * var(--banner-scale) * var(--current-font-size)); 116 | color: #aaa; 117 | white-space: nowrap; /* Prevent line breaks */ 118 | } 119 | 120 | .preview-info-brand { 121 | position: absolute; 122 | right: 0; 123 | top: 0; 124 | height: 100%; 125 | display: flex; 126 | align-items: center; 127 | } 128 | 129 | .preview-info-brand img { 130 | height: calc(var(--base-height) * var(--banner-scale)); 131 | } 132 | 133 | /* Exhibition Styles */ 134 | .exhibition { 135 | display: flex; 136 | justify-content: center; 137 | margin-top: 20px; 138 | margin-bottom: 20px; 139 | gap: 1rem; 140 | padding: 1rem; 141 | } 142 | 143 | .exhibition .ant-btn { 144 | transition: background-color 0.3s ease, transform 0.2s ease; 145 | } 146 | 147 | .exhibition .ant-btn:hover { 148 | background-color: #4a90e2; 149 | transform: translateY(-2px); 150 | } 151 | 152 | .op { 153 | display: flex; 154 | justify-content: center; 155 | margin-top: 20px; 156 | gap: 1rem; 157 | padding: 1rem; 158 | } 159 | 160 | .op .ant-btn { 161 | transition: background-color 0.3s ease, transform 0.2s ease; 162 | } 163 | 164 | .op .ant-btn:hover { 165 | background-color: #4a90e2; 166 | transform: translateY(-2px); 167 | } 168 | 169 | /* Props Section Styles */ 170 | 171 | .props { 172 | margin-top: 24px; 173 | } 174 | 175 | .props .ant-form-horizontal .ant-form-item { 176 | margin-top: 16px; 177 | margin-bottom: 16px; 178 | } 179 | 180 | .props-title { 181 | margin-bottom: 16px; /* Space below the title */ 182 | color: #2c3e50; /* Darker color for the title */ 183 | font-weight: 600; /* Bold title for emphasis */ 184 | } 185 | 186 | .props-option { 187 | background-color: #ffffff; /* White background for the props container */ 188 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); /* Deeper shadow for more depth */ 189 | border-radius: 12px; /* Rounded corners for a modern look */ 190 | padding: 24px; /* Increased padding for better spacing */ 191 | transition: transform 0.2s ease; /* Smooth transition for hover effect */ 192 | } 193 | 194 | .props-option:hover { 195 | transform: translateY(-2px); /* Lift effect on hover */ 196 | } 197 | 198 | /* Input and Select Styles */ 199 | .ant-input, 200 | .ant-select-selector { 201 | border-radius: 8px; /* Rounded corners for inputs and selects */ 202 | border: 1px solid #d9d9d9; /* Light gray border */ 203 | transition: border-color 0.3s ease, box-shadow 0.3s ease; /* Smooth transition for focus effects */ 204 | padding: 10px; /* Increased padding for better usability */ 205 | } 206 | 207 | .ant-input:focus, 208 | .ant-select-selector:focus { 209 | border-color: #007bff; /* Blue border on focus */ 210 | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2); /* Subtle blue shadow on focus */ 211 | } 212 | 213 | /* Slider Styles */ 214 | .ant-slider { 215 | margin-top: 8px; /* Space above the slider */ 216 | } 217 | 218 | /* Typography Styles */ 219 | .ant-typography { 220 | color: #333; /* Darker text color for better readability */ 221 | } 222 | 223 | /* Button Styles */ 224 | .ant-btn { 225 | border-radius: 8px; /* Rounded corners for buttons */ 226 | transition: background-color 0.3s ease, transform 0.2s ease; /* Smooth transition for hover effects */ 227 | } 228 | 229 | .ant-btn:hover { 230 | background-color: #0056b3; /* Darker blue on hover */ 231 | transform: translateY(-2px); /* Lift effect on hover */ 232 | } 233 | 234 | /* Switch Styles */ 235 | .switch-title { 236 | color: #aaa; /* Light gray color for the title */ 237 | font-family: var(--current-font-family), system-ui; 238 | font-size: 12px; /* Font size for the title */ 239 | } 240 | 241 | .ant-switch { 242 | border-radius: 20px; /* Rounded corners for a modern look */ 243 | background-color: #e0e0e0; /* Light gray background color */ 244 | transition: background-color 0.3s ease; /* Smooth transition for background color */ 245 | } 246 | 247 | .ant-switch-checked { 248 | background-color: #a0c4ff; /* Light blue background color when checked */ 249 | } 250 | 251 | .ant-switch-checked:hover { 252 | background-color: #8ab8ff; /* Lighter shade on hover when checked */ 253 | } 254 | 255 | /* Tooltip Styles */ 256 | .ant-tooltip { 257 | border-radius: 8px; /* Rounded corners for tooltips */ 258 | background-color: #f0f0f0; /* Light background for better contrast */ 259 | color: #333; /* Dark text for readability */ 260 | padding: 8px; /* Padding for better spacing */ 261 | font-size: 14px; /* Font size for tooltip text */ 262 | } 263 | 264 | .ant-tooltip-arrow { 265 | border-color: #f0f0f0; /* Match arrow color with tooltip background */ 266 | } 267 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.wasm' { 2 | const content: any 3 | export default content 4 | } 5 | 6 | interface ExifData { 7 | tag: string 8 | value: string 9 | value_with_unit: string 10 | } 11 | 12 | export interface ExifParamsForm { 13 | model: string 14 | date: string 15 | gps: string 16 | device: string 17 | brand: string 18 | brand_url: string 19 | scale: number 20 | fontSize: string 21 | fontWeight: string 22 | fontFamily: string 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/BrandUtils.ts: -------------------------------------------------------------------------------- 1 | export const BrandsList = [ 2 | 'Apple', 3 | 'Canon', 4 | 'Dji', 5 | 'Fujifilm', 6 | 'Huawei', 7 | 'Leica', 8 | 'Xiaomi', 9 | 'Nikon Corporation', 10 | 'Sony', 11 | // Panasonic DMC- 和 DC- 都属于 Lumix 相机系列 12 | 'Panasonic', 13 | 'Ricoh', 14 | 'Olympus', 15 | // 影石 Insta360 16 | 'Arashi Vision', 17 | '未收录', 18 | ] 19 | 20 | // 获取品牌图标 URL 21 | export function getBrandUrl(brand: string): string { 22 | return `./brand/${brand === '未收录' ? 'unknow.svg' : `${brand}.svg`}` 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/ImageUtils.ts: -------------------------------------------------------------------------------- 1 | import type { ExifData, ExifParamsForm } from '../types' 2 | import moment from 'moment' 3 | import { BrandsList } from './BrandUtils' 4 | 5 | export const DefaultPictureExif = { 6 | model: 'XIAOMI 13 ULTRA', 7 | date: moment().format('YYYY.MM.DD HH:mm'), 8 | gps: `41°12'47"N 124°00'16"W`, 9 | device: '75mm f/1.8 1/33s ISO800', 10 | brand: 'leica', 11 | brand_url: './brand/leica.svg', 12 | scale: 0.8, 13 | fontSize: 'normal', 14 | fontWeight: 'bold', 15 | fontFamily: 'misans', 16 | } 17 | 18 | export const ExhibitionImages = [ 19 | './exhibition/apple.jpg', 20 | './exhibition/canon.jpg', 21 | './exhibition/dji.jpg', 22 | './exhibition/fujifilm.jpg', 23 | './exhibition/huawei.jpg', 24 | './exhibition/leica.jpg', 25 | './exhibition/xiaomi.jpg', 26 | './exhibition/nikon.jpg', 27 | './exhibition/sony.jpg', 28 | './exhibition/panasonic.jpg', 29 | ] 30 | 31 | // 格式化 GPS 数据 32 | export function formatGPS(gps: string | undefined, gpsRef: string | undefined): string { 33 | if (!gps) 34 | return '' 35 | const [degrees, minutes, seconds, dir] = gps 36 | .match(/(\d+\.?\d*)|([NSWE]$)/gim) 37 | .map(item => (!Number.isNaN(Number(item)) ? `${~~item}`.padStart(2, '0') : item)) 38 | if (gpsRef) 39 | return `${degrees}°${minutes}'${seconds}"${gpsRef}` 40 | else if (dir) 41 | return `${degrees}°${minutes}'${seconds}"${dir}` 42 | else return `${degrees}°${minutes}'${seconds}"` 43 | } 44 | 45 | // 格式化品牌 46 | export function formatBrand(make: string | undefined): string { 47 | if ((make || '') === 'Arashi Vision') { 48 | return 'insta360' 49 | } 50 | const brand = (make || '').toLowerCase() 51 | for (const b of BrandsList.map(b => b.toLowerCase())) { 52 | if (brand.includes(b)) { 53 | return b 54 | } 55 | } 56 | return brand 57 | } 58 | 59 | // 格式化曝光时间 60 | export function formatExposureTime(exposureTime: string | undefined): string { 61 | if (!exposureTime) 62 | return '' 63 | const [numerator, denominator] = exposureTime.split('/').filter(Boolean).map(item => Math.floor(Number(item))) 64 | return [numerator, denominator].join('/') 65 | } 66 | 67 | // 格式化拍摄时间 68 | export function formatDateTimeOriginal(dateTimeOriginal: string | undefined): string { 69 | if (!dateTimeOriginal) 70 | return moment().format('YYYY.MM.DD HH:mm') 71 | return moment(dateTimeOriginal).format('YYYY-MM-DD HH:mm') 72 | } 73 | 74 | export function formatModel(model: string, brand: string): string { 75 | const camera_model: string = model.replace(/[",]/g, '') 76 | if (brand === 'sony') { 77 | return camera_model.replace(/[",]/g, '').replace('ILCE-', 'α').toLowerCase() 78 | } 79 | if (brand === 'nikon corporation') { 80 | return camera_model.replace(/Z/gi, 'ℤ') 81 | } 82 | if (brand === 'panasonic') { 83 | if (camera_model.startsWith('DMC-') || camera_model.startsWith('DC-')) 84 | return `LUMIX ${camera_model}` 85 | } 86 | return camera_model 87 | } 88 | 89 | // 解析 EXIF 数据 90 | export function parseExifData(data: ExifData[]): Partial { 91 | const exifValues = new Map(data.map(item => [item.tag, item.value])) 92 | const exifValuesWithUnit = new Map(data.map(item => [item.tag, item.value_with_unit])) 93 | const make: string = (exifValues.get('Make') || '').replace(/[",]/g, '') 94 | const brand: string = formatBrand(make || 'unknow') 95 | if (brand === 'unknow') { 96 | return DefaultPictureExif 97 | } 98 | 99 | const exif = { 100 | GPSLatitude: '', 101 | GPSLatitudeRef: '', 102 | GPSLongitude: '', 103 | GPSLongitudeRef: '', 104 | FocalLengthIn35mmFilm: '', 105 | FocalLength: '', 106 | FNumber: '', 107 | ExposureTime: '', 108 | PhotographicSensitivity: '', 109 | Model: '', 110 | Make: '', 111 | DateTimeOriginal: '', 112 | } 113 | exif.Make = make 114 | exif.Model = `${formatModel((exifValues.get('Model') || ''), brand)}` 115 | exif.GPSLatitude = exifValues.get('GPSLatitude') || '' 116 | exif.GPSLatitudeRef = exifValues.get('GPSLatitudeRef') || '' 117 | exif.GPSLongitude = exifValues.get('GPSLongitude') || '' 118 | exif.GPSLongitudeRef = exifValues.get('GPSLongitudeRef') || '' 119 | exif.FocalLengthIn35mmFilm = exifValuesWithUnit.get('FocalLengthIn35mmFilm') || '' 120 | exif.FocalLength = exifValuesWithUnit.get('FocalLength') || '' 121 | exif.FNumber = exifValuesWithUnit.get('FNumber') || '' 122 | exif.ExposureTime = exifValues.get('ExposureTime') || '' 123 | exif.PhotographicSensitivity = exifValues.get('PhotographicSensitivity') || '' 124 | exif.DateTimeOriginal = exifValues.get('DateTimeOriginal') || '' 125 | 126 | const gps = `${formatGPS(exif.GPSLatitude, exif.GPSLatitudeRef)} ${formatGPS(exif.GPSLongitude, exif.GPSLongitudeRef)}` 127 | const device = [ 128 | `${(exif.FocalLengthIn35mmFilm || exif.FocalLength).replace(/\s+/g, '')}`, 129 | exif.FNumber?.split('/')?.map((n, i) => (i ? (+n).toFixed(1) : n)).join('/'), 130 | exif.ExposureTime ? `${formatExposureTime(exif.ExposureTime)}s` : '', 131 | exif.PhotographicSensitivity ? `ISO${exif.PhotographicSensitivity}` : '', 132 | ] 133 | .filter(Boolean) 134 | .join(' ') 135 | return { 136 | model: exif.Model || 'PICSEAL', 137 | date: `${formatDateTimeOriginal(exif.DateTimeOriginal)}`, 138 | gps, 139 | device, 140 | brand, 141 | } 142 | } 143 | 144 | // 在组件初始化时随机选择一张照片 145 | export function getRandomImage() { 146 | const randomIndex = Math.floor(Math.random() * ExhibitionImages.length) 147 | return ExhibitionImages[randomIndex] 148 | } 149 | 150 | export function dataURLtoBlob(dataURL: string): Blob { 151 | const byteString: string = atob(dataURL.split(',')[1]) 152 | const mimeString: string = dataURL.split(',')[0].split(':')[1].split(';')[0] 153 | const ab = new ArrayBuffer(byteString.length) 154 | const ia = new Uint8Array(ab) 155 | for (let i: number = 0; i < byteString.length; i++) { 156 | ia[i] = byteString.charCodeAt(i) 157 | } 158 | return new Blob([ab], { type: mimeString }) 159 | } 160 | -------------------------------------------------------------------------------- /src/utils/JpegExifUtils.ts: -------------------------------------------------------------------------------- 1 | const SOS = 0xFFDA 2 | const APP1 = 0xFFE1 3 | const EXIF = 0x45786966 4 | const JPEG = 0xFFD8 // JPEG start marker 5 | 6 | export function extractExifRaw(raw: Blob): Promise { 7 | return new Promise((resolve, reject) => { 8 | const reader = new FileReader() 9 | reader.onloadend = async (e) => { 10 | const buffer = e.target?.result 11 | if (!buffer) 12 | return reject(new Error('Failed to read raw image data')) 13 | 14 | const view = new DataView(buffer) 15 | let offset = 0 16 | if (view.getUint16(offset) !== JPEG) 17 | return reject(new Error('not a valid jpeg')) 18 | offset += 2 19 | 20 | while (offset < view.byteLength) { 21 | const marker = view.getUint16(offset) 22 | if (marker === SOS) 23 | break 24 | const size = view.getUint16(offset + 2) 25 | if (marker === APP1 && view.getUint32(offset + 4) === EXIF) 26 | return resolve(raw.slice(offset, offset + 2 + size)) 27 | offset += 2 + size 28 | } 29 | return resolve(new Blob()) 30 | } 31 | reader.readAsArrayBuffer(raw) 32 | }) 33 | } 34 | 35 | export function embedExifRaw(exifRaw: Blob, targetImg: Blob): Blob { 36 | return new Blob([targetImg.slice(0, 2), exifRaw, targetImg.slice(2)], { 37 | type: 'image/jpeg', 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "jsx": "react-jsx", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "moduleDetection": "force", 8 | "useDefineForClassFields": true, 9 | "module": "ESNext", 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "Bundler", 13 | "allowImportingTsExtensions": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noEmit": true, 21 | "isolatedModules": true, 22 | "skipLibCheck": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "./tsconfig.app.json" }, 4 | { "path": "./tsconfig.node.json" } 5 | ], 6 | "files": [] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "moduleDetection": "force", 7 | "module": "ESNext", 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | 13 | /* Linting */ 14 | "strict": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noEmit": true, 19 | "isolatedModules": true, 20 | "skipLibCheck": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "installCommand": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source $HOME/.cargo/env && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && npm install", 3 | "buildCommand": "source $HOME/.cargo/env && npm run build", 4 | "git": { 5 | "deploymentEnabled": { 6 | "feature/update": false, 7 | "gh-pages": false 8 | } 9 | }, 10 | "github": { 11 | "autoJobCancelation": false 12 | }, 13 | "headers": [ 14 | { 15 | "source": "/sw.js", 16 | "headers": [ 17 | { 18 | "key": "Cache-Control", 19 | "value": "public, max-age=0, must-revalidate" 20 | } 21 | ] 22 | }, 23 | { 24 | "source": "(.*)", 25 | "headers": [ 26 | { 27 | "key": "Cache-Control", 28 | "value": "public, s-maxage=31536000, max-age=31536000" 29 | }, 30 | { 31 | "key": "Vercel-CDN-Cache-Control", 32 | "value": "max-age=31536000" 33 | } 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { visualizer } from 'rollup-plugin-visualizer' 3 | import { defineConfig } from 'vite' 4 | import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' 5 | import { VitePWA } from 'vite-plugin-pwa' 6 | import topLevelAwait from 'vite-plugin-top-level-await' 7 | import wasm from 'vite-plugin-wasm' 8 | 9 | export default defineConfig({ 10 | plugins: [ 11 | react(), 12 | wasm(), 13 | topLevelAwait(), 14 | VitePWA(), 15 | visualizer({ open: false }), 16 | ViteImageOptimizer({ 17 | test: /\.(jpe?g|png|gif|tiff|webp|svg|avif)$/i, 18 | includePublic: true, 19 | logStats: true, 20 | ansiColors: true, 21 | png: { 22 | quality: 100, 23 | }, 24 | jpeg: { 25 | quality: 100, 26 | }, 27 | jpg: { 28 | quality: 100, 29 | }, 30 | tiff: { 31 | quality: 100, 32 | }, 33 | gif: {}, 34 | webp: { 35 | lossless: true, 36 | }, 37 | avif: { 38 | lossless: true, 39 | }, 40 | }), 41 | ], 42 | server: { 43 | port: 3000, 44 | }, 45 | build: { 46 | outDir: 'dist', 47 | target: 'esnext', 48 | }, 49 | optimizeDeps: { 50 | exclude: ['picseal'], 51 | }, 52 | // base: 'https://zhiweio.github.io/picseal/', 53 | }) 54 | --------------------------------------------------------------------------------