├── src ├── common.js ├── images │ └── icon.png ├── util.js ├── common.css ├── settings.css ├── components │ └── pie │ │ └── index.js ├── settings.ts ├── index.css ├── index.ts ├── audio.js └── app.js ├── demo ├── jpg-after.jpg ├── png-after.png ├── jpg-before.jpg ├── png-before.png ├── svg-after.svg └── svg-before.svg ├── tsconfig.json ├── public ├── iconfont.woff ├── hummingbird-test-audio.mp3 ├── images │ ├── minimized.svg │ ├── convert.svg │ ├── video.svg │ ├── font.svg │ ├── close.svg │ ├── settings.svg │ ├── code.svg │ ├── history.svg │ ├── import.svg │ ├── wait.svg │ ├── source │ │ ├── code.svg │ │ ├── history.svg │ │ └── video.svg │ └── audio.svg ├── convert.css ├── code.css ├── video.css ├── audio.css ├── code-zh-CN.html ├── code.html ├── video-zh-CN.html ├── video.html ├── audio-zh-CN.html ├── audio.html ├── convert-zh-CN.html ├── convert.html ├── settings-zh-CN.html ├── settings.html ├── index-zh-CN.html ├── index.html ├── code.js ├── convert.js └── video.js ├── .github ├── FUNDING.yml └── workflows │ ├── linux.yml │ ├── macos.yml │ └── windows.yml ├── .gitignore ├── locales ├── zh-CN.json └── en-US.json ├── configuration.js ├── esbuild.config.mjs ├── LICENSE ├── package.json ├── README-zh-CN.md ├── README.md └── main.js /src/common.js: -------------------------------------------------------------------------------- 1 | import "./common.css"; 2 | -------------------------------------------------------------------------------- /demo/jpg-after.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/demo/jpg-after.jpg -------------------------------------------------------------------------------- /demo/png-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/demo/png-after.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/jpg-before.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/demo/jpg-before.jpg -------------------------------------------------------------------------------- /demo/png-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/demo/png-before.png -------------------------------------------------------------------------------- /public/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/public/iconfont.woff -------------------------------------------------------------------------------- /src/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/src/images/icon.png -------------------------------------------------------------------------------- /public/hummingbird-test-audio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leibnizli/hummingbird/HEAD/public/hummingbird-test-audio.mp3 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 'leibnizli' 4 | custom: ["https://buy.arayofsunshine.dev"] 5 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | function getUserHome() { 2 | return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; 3 | } 4 | 5 | export { getUserHome }; 6 | -------------------------------------------------------------------------------- /public/images/minimized.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /dist/ 4 | *.dmg 5 | .idea 6 | *lock.json 7 | electron-packager-darwin-template 8 | electron-packager-mac 9 | *.zip 10 | *.log 11 | 12 | out/ 13 | .env 14 | env.md 15 | assets 16 | src/font/demo 17 | 18 | electron-builder.yml 19 | memo.md 20 | *.sh 21 | -------------------------------------------------------------------------------- /locales/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "waiting": "拖放一个或多个文件或目录", 3 | "dragenter": "松开鼠标,就开始了", 4 | "after": "个文件被压缩,压缩空间", 5 | "generate":"生成一个新字体", 6 | "merge": "将一个或多个 SVG 文件拖至此", 7 | "add": "拖放要修改的字体 (.ttf) 文件", 8 | "continue": "可以继续拖放SVG文件至此", 9 | "cut":"将字体 (.ttf) 文件拖放到此处", 10 | "current": "当前文件:", 11 | "cutTip": "在此输入需要保留的字符" 12 | } 13 | -------------------------------------------------------------------------------- /public/convert.css: -------------------------------------------------------------------------------- 1 | body { background:#fff; font-size:12px; } 2 | .convert { padding:20px; } 3 | .convert-hd { 4 | strong { color:#3E2F00; } 5 | } 6 | .convert-hd p { } 7 | .convert-bd { padding:20px 0; 8 | p { padding:4px 0; } 9 | .convert-bd-set { margin:10px 0;} 10 | ul { display:flex; flex-wrap:wrap; } 11 | li { width:60px; } 12 | input { vertical-align:middle; margin-right:6px; } 13 | } 14 | .convert-ft { 15 | .convert-additional { } 16 | } 17 | .convert-log { padding:10px; color:#3E2F00; } 18 | -------------------------------------------------------------------------------- /configuration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const {getUserHome} = require('./src/util.js'); 3 | const nconf = require('nconf'); 4 | 5 | const config = nconf.file({file: getUserHome() + '/hummingbird-config.json'}); 6 | 7 | function set(settingKey, settingValue) { 8 | config.set(settingKey, settingValue); 9 | config.save(); 10 | } 11 | 12 | function get(settingKey) { 13 | config.load(); 14 | return config.get(settingKey); 15 | } 16 | 17 | module.exports = { 18 | set, 19 | get 20 | } 21 | console.log(process.platform) 22 | 23 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as esbuild from 'esbuild' 3 | 4 | let ctx = await esbuild.context({ 5 | entryPoints: ['./src/common.js','./src/index.ts','./src/settings.ts','./src/audio.js'], 6 | bundle: true, 7 | minify: true, 8 | sourcemap: true, 9 | // platform: 'node', 10 | packages: 'external', 11 | entryNames: '[name].bundle', 12 | outdir: 'public/assets', 13 | loader: { '.ttf': 'file' }, 14 | //loader: { '.ttf': 'dataurl' }, 15 | }) 16 | await ctx.watch() 17 | console.log('watching...') 18 | -------------------------------------------------------------------------------- /public/images/convert.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/images/video.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/code.css: -------------------------------------------------------------------------------- 1 | body { background:#fff; font-size:12px; } 2 | .code { padding:20px; } 3 | .code-hd { 4 | strong { color:#3E2F00; } 5 | } 6 | .code-hd p { } 7 | .code-bd { padding:20px 0; 8 | p { padding:4px 0; } 9 | .code-bd-set { margin:10px 0;} 10 | ul { display:flex; flex-wrap:wrap; } 11 | li { width:60px; } 12 | input { vertical-align:middle; margin-right:6px; } 13 | } 14 | .code-ft { 15 | padding:10px 0; 16 | border-top:2px dashed rgba(64,100,138,.2); 17 | .code-additional { } 18 | } 19 | .code-log { padding:10px; color:#3E2F00; } 20 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: linux 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | 9 | publish_on_linux: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@master 14 | with: 15 | node-version: 18.17.1 16 | - name: install dependencies 17 | run: npm install 18 | - name: build 19 | run: npm run build 20 | - name: publish 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | run: npm run publish 24 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: macOS 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | 9 | publish_on_mac: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@master 14 | with: 15 | node-version: 18.17.1 16 | - name: install dependencies 17 | run: npm install 18 | - name: build 19 | run: npm run build 20 | - name: publish 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | run: npm run publish 24 | -------------------------------------------------------------------------------- /public/images/font.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: windows 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | 9 | publish_on_win: 10 | runs-on: windows-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@master 14 | with: 15 | node-version: 18.17.1 16 | - name: install dependencies 17 | run: npm install 18 | - name: build 19 | run: npm run build 20 | - name: publish 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | run: npm run publish 24 | -------------------------------------------------------------------------------- /locales/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "waiting": "Drop one or more files or directories", 3 | "dragenter": "Release the mouse, and the process begins", 4 | "after": "files have been processed and the compressed space is", 5 | "generate": "Generate a new font", 6 | "merge": "Drag one or more SVG files to this", 7 | "add": "Drop a font (.ttf) file to be modified", 8 | "continue": "You can continue dragging and dropping SVG files here", 9 | "cut": "Drop a font (.ttf) file to this", 10 | "current": "Current file:", 11 | "cutTip": "Enter the characters you want to keep here" 12 | } 13 | -------------------------------------------------------------------------------- /public/video.css: -------------------------------------------------------------------------------- 1 | body { background:#fff; font-size:12px; } 2 | .video { padding:20px; } 3 | .video-hd { 4 | strong { color:#3E2F00; } 5 | } 6 | .video-hd p { } 7 | .video-bd { padding:20px 0; 8 | p { padding:4px 0; } 9 | .video-bd-set { margin:10px 0;} 10 | ul { display:flex; flex-wrap:wrap; } 11 | li { width:60px; } 12 | input { vertical-align:middle; margin-right:6px; } 13 | } 14 | .video-ft { 15 | padding:10px 0; 16 | /*border-top:2px dashed rgba(64,100,138,.2);*/ 17 | .video-additional { } 18 | } 19 | .video-log { padding:10px; color:#3E2F00; } 20 | -------------------------------------------------------------------------------- /public/images/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/images/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/audio.css: -------------------------------------------------------------------------------- 1 | body { background:#fff; font-size:12px; } 2 | .audio { 3 | padding:20px; 4 | h2 { padding:5px 0; font-size:10px; font-weight:bold; } 5 | } 6 | .audio-hd { 7 | strong { color:#3E2F00; } 8 | p { line-height:28px; } 9 | } 10 | .audio-hd p { } 11 | .audio-bd { padding:20px 0; 12 | p { padding:4px 0; } 13 | .audio-bd-set { margin:10px 0;} 14 | ul { display:flex; flex-wrap:wrap; } 15 | li { width:60px; } 16 | input { vertical-align:middle; margin-right:6px; } 17 | } 18 | .audio-ft { 19 | padding:10px 0; 20 | /*border-top:2px dashed rgba(64,100,138,.2);*/ 21 | .audio-additional { } 22 | } 23 | .waveform { padding:20px 0; } 24 | -------------------------------------------------------------------------------- /public/images/code.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/images/history.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/import.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/images/wait.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/source/code.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/common.css: -------------------------------------------------------------------------------- 1 | body,h1,h2,h3,h4,h5,h6,p,blockquote,pre,form,input,textarea,button,fieldset,legend,figure,ul { margin: 0; padding: 0;}body { font: 14px/1.5 Corbel, "Helvetica Neue", Helvetica, Arial; ; color: #666;}button,input,select,textarea { vertical-align: top; font-size: 12px; font-family: Corbel, "Helvetica Neue", Helvetica, Arial;}header,footer,aside,article,section,hgroup,nav,figure { display: block;}h1,h2,h3,h4,h5,h6 { font-size: 100%; font-weight: normal; font-family: "Century Gothic", "Microsoft YaHei";}ol,ul { list-style: none;}del { text-decoration: line-through;}table { border-collapse: collapse; border-spacing: 0;}img { vertical-align: top;}a img { border: none;}strong { font-weight: bold;}cite,em,i { font-style: italic;}ins { background: #ffc; text-decoration: none;}abbr,acronym { border-bottom: 1px dotted #666; cursor: help;}sup,sub { height: 0; line-height: 1; position: relative; vertical-align: baseline;}sup { bottom: 1ex;}sub { top: 0.5ex;}a { color: #3B5998; text-decoration: none;}:focus { outline: 0;}.ui-clear:after { height: 0; display: block; content: "\200B"; clear: both;}* { box-sizing: border-box;} 2 | -------------------------------------------------------------------------------- /public/code-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 获取文件编码 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

可选择 .png、.webp、.jpeg、.jpg、.gif、.tiff、.avif、.svg、.ttf、.woff、.woff2。

15 |
16 | 17 |
18 |

19 |

20 | 21 |
22 |
23 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Leibniz Li. 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. -------------------------------------------------------------------------------- /public/code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Get file encoding 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

You can choose from .png, .webp, .jpeg, .jpg, .gif, .tiff, .avif, .svg, .ttf, .woff, .woff2.

15 |
16 | 17 |
18 |

19 |

20 | 21 |
22 |
23 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/settings.css: -------------------------------------------------------------------------------- 1 | input[type='range'] { -webkit-appearance: none; appearance: none; background-color: #FFF; 2 | vertical-align: middle; width: 180px; } 3 | input[type=range]::-webkit-slider-runnable-track { -webkit-appearance: none; appearance: none; background-color: #e4e8ec; height: 4px; vertical-align: middle; } 4 | input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; background-color: #8da2b9; height: 20px; width: 10px; vertical-align: middle; cursor:move; margin-top: -8px;} 5 | html,body { height:100%; } 6 | body { display: -webkit-box; -webkit-box-align: center; -webkit-box-pack: center; } 7 | .settings { 8 | h2 { padding:5px 0; font-size:10px; font-weight:bold; } 9 | } 10 | .settings-backup { height:32px; padding:10px; background:#f9f9f9; line-height:12px; margin-bottom:7px; text-align:center; font-size:12px; } 11 | .settings-backup span { margin-left:7px; } 12 | .settings-tb { width:100%; text-align: center; } 13 | .settings-tb caption { color: #000; padding-bottom: 5px; } 14 | .settings-name { text-align: right; } 15 | .settings-value { min-width:40px; } 16 | .settings-tb td { padding: 8px 10px; border-right: solid 1px #ddd; border-bottom: solid 1px #ddd; font-size: 12px; } 17 | .settings-tb tr td:last-child { border-right: 0; } 18 | .settings-tb tr:last-child td { border-bottom: 0; } 19 | .settings-ft { 20 | font-size:12px; text-align:center; margin-top:8px; 21 | } 22 | -------------------------------------------------------------------------------- /public/images/source/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | 10 | 12 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/video-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 视频编辑 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

可选择 .mov、.mp4、.avi。

15 |
16 | 17 |
18 |

19 |

20 |

21 |
22 |

选择导出的格式:

23 |
    24 |
  • 25 | 28 |
  • 29 |
  • 30 | 31 |
  • 32 |
  • 33 | 34 |
  • 35 |
  • 36 | 37 |
  • 38 |
39 |
40 |

41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Video editing 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Choose from .mov, .mp4, .avi.

15 |
16 | 17 |
18 |

19 |

20 |

21 |
22 |

Select the export format:

23 |
    24 |
  • 25 | 28 |
  • 29 |
  • 30 | 31 |
  • 32 |
  • 33 | 34 |
  • 35 |
  • 36 | 37 |
  • 38 |
39 |
40 |

41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/audio-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 音频编辑转换 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

选择mp3、wav

15 |

选择一个文件时可剪辑,选择多个文件时只能导出不同格式的文件不可剪辑。

16 |

17 |
18 |
19 |
20 |

21 | 24 | 27 |

28 |
29 |

在音频波浪上拖动鼠标即可选择需要保留的音频区间,可以同时添加多个选区,选区的范围可通过拖动左右边栏改变大小,点击选区选择当前选区,按下Delete键可删除当前选区,按下Space键可以播放或暂停音频。

30 |
31 |
32 |

选择导出的格式:

33 |
    34 |
  • 35 | 38 |
  • 39 |
  • 40 | 41 |
  • 42 |
43 |
44 |

45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /public/images/source/video.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/pie/index.js: -------------------------------------------------------------------------------- 1 | function Pie (){ 2 | this.pie = null; 3 | this.progressFill = null; 4 | this.percentEl = null; 5 | this.lastPercent = -1; 6 | }; 7 | Pie.prototype = { 8 | _ensureElements: function() { 9 | // Always try to get elements if they don't exist 10 | if (!this.pie) { 11 | this.pie = document.querySelector('.pie'); 12 | if (!this.pie) { 13 | console.warn('Pie element not found in DOM'); 14 | } 15 | } 16 | if (!this.progressFill) { 17 | this.progressFill = document.querySelector('.pie-progress-fill'); 18 | if (!this.progressFill) { 19 | console.warn('Pie progress fill element not found in DOM'); 20 | } 21 | } 22 | if (!this.percentEl) { 23 | this.percentEl = document.querySelector('.pie-percent'); 24 | if (!this.percentEl) { 25 | console.warn('Pie percent element not found in DOM'); 26 | } 27 | } 28 | }, 29 | set: function(percent) { 30 | // Skip if same as last update 31 | if (this.lastPercent === percent) { 32 | return; 33 | } 34 | 35 | // Always re-check elements in case DOM was updated 36 | this._ensureElements(); 37 | 38 | // If elements still not found, retry after a short delay 39 | if (!this.pie || !this.progressFill || !this.percentEl) { 40 | console.warn('Pie elements not ready, retrying...'); 41 | setTimeout(() => { 42 | this.set(percent); 43 | }, 50); 44 | return; 45 | } 46 | 47 | const deg = 360 * (percent / 100); 48 | 49 | if (this.pie && percent > 50) { 50 | this.pie.classList.add('gt-50'); 51 | } 52 | 53 | if (this.progressFill) { 54 | this.progressFill.style.transform = `rotate(${deg}deg)`; 55 | } 56 | 57 | if (this.percentEl) { 58 | this.percentEl.innerHTML = percent + '%'; 59 | } 60 | 61 | this.lastPercent = percent; 62 | } 63 | } 64 | module.exports = Pie; -------------------------------------------------------------------------------- /public/audio.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audio editing conversion 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Select mp3, wav

15 |

When you select one file, you can edit it. When you select multiple files, you can only export files in different formats and cannot edit them.

16 |

17 |
18 |
19 |
20 |

21 | 24 | 27 |

28 |
29 |

Drag the mouse on the audio wave to select the audio range that needs to be retained. Multiple selections can be added at the same time. The range of the selection can be changed in size by dragging the left and right sidebars. Click the selection to select the current selection and press Delete key to delete the current selection, and press the Space key to play or pause the audio.

30 |
31 |
32 |

Select export format:

33 |
    34 |
  • 35 | 38 |
  • 39 |
  • 40 | 41 |
  • 42 |
43 |
44 |

45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hummingbird", 3 | "version": "6.0.3", 4 | "description": "Support the Drop folder compression.", 5 | "main": "main.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/leibnizli/hummingbird.git" 9 | }, 10 | "author": "leibnizli", 11 | "license": "ISC", 12 | "bugs": { 13 | "url": "https://github.com/leibnizli/hummingbird/issues" 14 | }, 15 | "homepage": "https://github.com/leibnizli/hummingbird#readme", 16 | "build": { 17 | "appId": "dev.arayofsunshine.hummingbird", 18 | "mac": { 19 | "notarize": false, 20 | "category": "tool", 21 | "target": { 22 | "target": "default", 23 | "arch": [ 24 | "arm64" 25 | ] 26 | } 27 | }, 28 | "win": { 29 | "target": [ 30 | { 31 | "target": "nsis", 32 | "arch": [ 33 | "x64" 34 | ] 35 | } 36 | ] 37 | }, 38 | "nsis": { 39 | "oneClick": false, 40 | "allowToChangeInstallationDirectory": true 41 | }, 42 | "asar": false 43 | }, 44 | "scripts": { 45 | "esbuild": "rimraf ./assets && node esbuild.config.mjs", 46 | "dev": "electron .", 47 | "dir": "rimraf ./dist && electron-builder --dir", 48 | "pack": "rimraf ./dist && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder build --mac --publish never" 49 | }, 50 | "devDependencies": { 51 | "@electron/notarize": "2.3.0", 52 | "@types/node": "^16.x", 53 | "@types/sharp": "^0.30.x", 54 | "cross-env": "^7.0.3", 55 | "electron": "^35.0.1", 56 | "electron-builder": "24.13.3", 57 | "esbuild": "0.19.11", 58 | "typescript": "^4.x" 59 | }, 60 | "dependencies": { 61 | "express": "^4.19.2", 62 | "electron-log": "^5.1.2", 63 | "electron-updater": "^6.1.7", 64 | "ffmpeg-static": "^5.2.0", 65 | "ffprobe-static": "^3.1.0", 66 | "fluent-ffmpeg": "^2.1.2", 67 | "gulp": "^4.0.0", 68 | "gulp-clean-css": "^3.4.1", 69 | "gulp-htmlmin": "^3.0.0", 70 | "gulp-rename": "^1.2.2", 71 | "gulp-uglify": "^3.0.0", 72 | "heic-convert": "^2.1.0", 73 | "i18n": "^0.15.1", 74 | "imagemin": "^5.3.1", 75 | "imagemin-gifsicle": "^6.0.1", 76 | "imagemin-optipng": "^5.2.1", 77 | "imagemin-pngquant": "9.0.2", 78 | "imagemin-svgo": "^7.0.0", 79 | "imagemin-webp": "^5.0.0", 80 | "mime": "^3.0.0", 81 | "nconf": "0.11.4", 82 | "sharp": "^0.33.2", 83 | "to-ico": "^1.1.5", 84 | "wavesurfer.js": "^7.7.10" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /public/convert-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 批量转换 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

可选择.png、.webp、.jpeg、.jpg、.gif、.tiff、.avif、.heic。

15 |

.heic只能转换为.jpeg或.png格式。

16 |

.ico、.icns只能由.png格式转换。

17 |
18 |
19 |

20 |
21 |

选择导出的格式:

22 |
    23 |
  • 24 | 27 |
  • 28 |
  • 29 | 30 |
  • 31 |
  • 32 | 33 |
  • 34 |
  • 35 | 36 |
  • 37 | 38 | 39 | 40 |
  • 41 | 42 |
  • 43 |
  • 44 | 45 |
  • 46 | 47 | 48 | 49 |
  • 50 | 51 |
  • 52 |
  • 53 | 54 |
  • 55 |
56 |
57 |

58 |
59 |
60 |
61 |
62 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /public/convert.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Convert image format 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

You can choose from .png, .webp, .jpeg, .jpg, .gif, .tiff, .avif, and .heic.

15 |

.heic can only be converted to .jpeg or .png format.

16 |

.ico and .icns can only be converted from .png format.

17 |
18 |
19 |

20 |
21 |

Select the export format:

22 |
    23 |
  • 24 | 27 |
  • 28 |
  • 29 | 30 |
  • 31 |
  • 32 | 33 |
  • 34 |
  • 35 | 36 |
  • 37 | 38 | 39 | 40 |
  • 41 | 42 |
  • 43 |
  • 44 | 45 |
  • 46 | 47 | 48 | 49 |
  • 50 | 51 |
  • 52 |
  • 53 | 54 |
  • 55 |
56 |
57 |

58 |
59 |
60 |
61 |
62 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /public/images/audio.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/settings-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 压缩设置 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

图片:

15 |
16 | 在压缩前备份原始文件(source目录) 17 |
18 | 19 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 48 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
最大宽度 26 | px 27 |
最大高度 33 | px 34 |
jpg 40 | 41 | 43 | 80 44 |
webp 49 | 50 | 52 | 85 53 |
png已设置最佳方案
gif无损压缩
svg已设置最佳方案
72 |

视频:

73 | 74 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 86 |
最大高度 81 | 82 |
87 |
88 |
89 | 91 |
92 |
93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /public/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Compression setting 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Image:

15 |
16 | Back up the original file before compression 17 |
18 | 19 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 48 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
Max Width 26 | px 27 |
Max Height 33 | px 34 |
jpg 40 | 41 | 43 | 80 44 |
webp 49 | 50 | 52 | 85 53 |
pngThe optimal solution has been set
gifLossless compression
svgThe optimal solution has been set
72 |

Video:

73 | 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 |
Max height 78 | 79 |
84 |
85 |
86 | 88 |
89 |
90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import "./settings.css"; 2 | import configuration from "../configuration.js"; 3 | import { shell, IpcRenderer } from "electron"; 4 | 5 | // 使用 import 替代 require 6 | import { ipcRenderer } from "electron"; 7 | 8 | // 定义配置项接口 9 | interface Configuration { 10 | backup: boolean; 11 | maxWidth: number; 12 | maxHeightVideo: number; 13 | maxHeight: number; 14 | jpg: number; 15 | webp: number; 16 | } 17 | 18 | // 定义设置处理类 19 | class SettingsHandler { 20 | private static set(el: HTMLInputElement, value: string): void { 21 | el.value = value; 22 | const targetEl = document.getElementById(el.dataset.target || '') as HTMLInputElement; 23 | if (targetEl) { 24 | targetEl.value = value; 25 | } 26 | } 27 | 28 | private static initializeSettings(): void { 29 | const backupInput = document.querySelector("input[name='backup']") as HTMLInputElement; 30 | if (backupInput) { 31 | backupInput.checked = configuration.get('backup'); 32 | } 33 | 34 | const inputs: Record = { 35 | "maxWidth": 'maxWidth', 36 | "maxHeightVideo": 'maxHeightVideo', 37 | "maxHeight": 'maxHeight' 38 | }; 39 | 40 | Object.entries(inputs).forEach(([id, configKey]) => { 41 | const element = document.getElementById(id) as HTMLInputElement; 42 | if (element) { 43 | element.value = configuration.get(configKey); 44 | } 45 | }); 46 | 47 | const qualitySettings = [configuration.get('jpg'), configuration.get('webp')]; 48 | document.querySelectorAll(".settings-range").forEach((item, i) => { 49 | this.set(item, qualitySettings[i]); 50 | }); 51 | } 52 | 53 | private static setupEventListeners(): void { 54 | // 备份设置监听 55 | document.addEventListener("change", (e: Event) => { 56 | const target = e.target as HTMLInputElement; 57 | if (target.matches("input[name='backup']")) { 58 | ipcRenderer.send('backup', target.checked); 59 | } 60 | }); 61 | 62 | // 尺寸设置监听 63 | document.addEventListener("input", (e: Event) => { 64 | const target = e.target as HTMLInputElement; 65 | const sizeSettings: Record = { 66 | "maxWidth": 'maxWidth', 67 | "maxHeightVideo": 'maxHeightVideo', 68 | "maxHeight": 'maxHeight' 69 | }; 70 | 71 | const setting = sizeSettings[target.id]; 72 | if (setting) { 73 | ipcRenderer.send(setting, target.value); 74 | } 75 | }); 76 | 77 | // 质量设置监听 78 | document.addEventListener("change", (e: Event) => { 79 | const target = e.target as HTMLInputElement; 80 | if (target.matches('.settings-range')) { 81 | const value = target.value; 82 | const targetKey = target.dataset.target; 83 | this.set(target, value); 84 | if (targetKey) { 85 | ipcRenderer.send('set-quality', targetKey, Number(value)); 86 | } 87 | } 88 | }); 89 | 90 | // 购买按钮监听 91 | document.addEventListener("click", (e: Event) => { 92 | const target = e.target as HTMLElement; 93 | if (target.id === 'buy') { 94 | // shell.openExternal("https://buy.arayofsunshine.dev"); 95 | } 96 | }); 97 | } 98 | public static initialize(): void { 99 | this.initializeSettings(); 100 | this.setupEventListeners(); 101 | } 102 | } 103 | 104 | // 初始化设置 105 | SettingsHandler.initialize(); 106 | 107 | -------------------------------------------------------------------------------- /demo/svg-after.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README-zh-CN.md: -------------------------------------------------------------------------------- 1 | 简体中文 | [English](./README.md) 2 | 3 | # hummingbird-h1 4 | 5 | 1. **Hummingbird**使用智能压缩技术来减少文件的大小,支持:jpg、png、webp、svg、gif、css、js、html、mp4、mov,可以设置压缩的同时等比例缩放图片或视频的尺寸。可以拖放文件夹压缩。 6 | 2. **Hummingbird**可以转换不同格式的图片,支持:png、webp、jpeg、jpg、gif、tiff、 avif、heic,可以导出不同格式的图片,支持:png、 webp、 jpeg、 jpg、 gif、 tiff、 avif、 ico、icns(仅苹果系统)格式的图片。 7 | 3. **Hummingbird**可以从视频中提取音频,可以将视频中的音频删除,可以将视频转换为gif,可以转换视频格式,支持mp4、mov、avi。 8 | 4. 可以裁取音频中的一段或多段,可以批量转换音频为mp3、wav格式。 9 | 10 | 11 | 12 | * jpg、png、webp、svg、gif、html压缩后会替换掉当前文件,可以在设置中开启备份,开启后会自动在当前目录新建`source`文件夹并备份处理前的文件。 13 | * css、js、mp4压缩后会生成一个带.min的新文件。 14 | 15 | ## 安装 16 | 17 | ### 最新版本 18 | 19 | #### 从GitHub下载 20 | 21 | * **macOS**(Apple Silicon, arm64)安装完毕后需要`系统设置`-`安全&隐私`中允许启动Hummingbird 22 | * ~~**macOS**~~(Intel) 23 | * **Windows** (>=10) 24 | * **iOS** 25 | 26 | ### 旧版本 v3.0.0 27 | 28 | 适用于windows老版本,win7,win8 29 | 30 | * **Windows**(v3.0.0,Google网盘) 31 | * **Windows**(v3.0.0,百度网盘) 32 | 33 | ## 帮助 34 | 35 | * [什么是Apple Silicon?](https://arayofsunshine.dev/blog/apple-silicon) 36 | * [macOS App打不开](https://arayofsunshine.dev/blog/macos-app-cannot-be-opened) 37 | 38 | ## 使用 39 | 40 | ### 压缩图片 41 | 42 | #### jpg压缩对比 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
41kB12kB
60 | 61 | #### png压缩对比 62 | 63 | 对于png24通道透明有比较好的压缩效果 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
28.9kB9.42kB
81 | 82 | #### svg压缩对比 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
5.47kB3.55kB
100 | 101 | ### 裁切音频 102 | 103 | audio 104 | 105 | ## 隐藏功能 106 | 107 | 通过菜单栏入口进入隐藏功能。 108 | 109 | * **Hummingbird**可以快速获取文件的Base64编码,支持:png、webp、jpeg、jpg、gif、tiff、avif、svg、ttf、woff、woff2。 110 | * **Hummingbird**可以查看压缩文件的历史记录。 111 | -------------------------------------------------------------------------------- /public/index-zh-CN.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hummingbird - 中文版 6 | 7 | 8 | 25 | 26 | 27 | 28 |
29 |
    30 |
  • 31 | 32 |
  • 33 |
  • 34 | 35 |
  • 36 |
  • 37 | 39 |
  • 40 |
  • 41 | 43 |
  • 44 |
  • 45 | 46 |
  • 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
  • 56 | 57 |
  • 58 |
  • 59 | 60 |
  • 61 |
62 | 63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | 83 | 84 | 118 | 119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hummingbird 7 | 8 | 9 | 26 | 27 | 28 | 29 |
30 |
    31 |
  • 32 | 33 |
  • 34 |
  • 35 | 36 |
  • 37 |
  • 38 | 40 |
  • 41 |
  • 42 | 44 |
  • 45 |
  • 46 | 47 |
  • 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
  • 57 | 58 |
  • 59 |
  • 60 | 61 |
  • 62 |
63 | 64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | 84 | 85 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .ui-app { 2 | height: 260px; 3 | overflow: hidden; 4 | box-shadow: inset 0 0 2px #fff, inset 0 0 100px #d5dbe2; 5 | -webkit-app-region: drag; 6 | } 7 | 8 | .ui-hd { 9 | display: flex; 10 | justify-content: center; 11 | height: 46px; 12 | margin: 0 20px; 13 | padding: 8px 0; 14 | -webkit-user-select: none; 15 | box-shadow: 0 12px 18px rgba(64, 100, 138, .4), inset 0 0 3px rgba(255, 255, 255, .6), 0 0 3px rgba(64, 100, 138, .3), 0 2px 4px #3b5c7f, inset 0 1px 1px rgba(64, 100, 138, .25); 16 | background-image: linear-gradient(to top, #dee7f0, #f1f6fa); 17 | border-radius: 0 0 3px 3px; 18 | } 19 | 20 | 21 | /*.ui-hd-drag { position:absolute; top:0; bottom:0; right:50px; left:107px; z-index:0; }*/ 22 | .ui-hd-icon { 23 | -webkit-app-region: no-drag; 24 | position: relative; 25 | z-index: 1; 26 | width: 34px; 27 | margin: 0 3px; 28 | color: rgba(64, 100, 138, 0.8); 29 | cursor: pointer; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | } 34 | 35 | .ui-hd-icon:after { 36 | position: absolute; 37 | right: -3px; 38 | top: 0; 39 | bottom: 0; 40 | width: 1px; 41 | background: rgba(255, 255, 255, 0); 42 | box-shadow: -1px 0 rgba(255, 255, 255, .25); 43 | background-image: linear-gradient(to top, rgba(64, 100, 138, .2), rgba(92, 144, 199, .2)); 44 | content: ""; 45 | } 46 | 47 | .ui-hd-icon:last-child:after { 48 | display: none; 49 | } 50 | 51 | .ui-hd-icon:hover { 52 | color: rgba(64, 100, 138, 1); 53 | } 54 | 55 | .close:hover { 56 | color: red; 57 | } 58 | 59 | /*.ui-bd { height:213px; overflow:hidden; }*/ 60 | .ui-tip { 61 | text-align: center; 62 | font-size: 12px; 63 | color: rgb(62 47 0 / 0.5); 64 | } 65 | 66 | .ui-area-icon { 67 | text-align: center; 68 | padding-bottom: 8px; 69 | } 70 | 71 | .ui-area { 72 | -webkit-app-region: no-drag; 73 | position: relative; 74 | z-index: 1; 75 | margin: 14px; 76 | height: 186px; 77 | padding: 30px 0; 78 | font-size: 12px; /*background:rgba(222,230,246,0.2);*/ 79 | display: grid; 80 | place-items: center; /*background:url("images/bg.jpg") no-repeat 0 0; */ 81 | color: rgba(64, 100, 138, 0.6); 82 | -webkit-user-select: none; 83 | } 84 | 85 | .ui-area-main a.click { 86 | font-weight: bold; 87 | text-decoration: underline 88 | } 89 | 90 | .ui-area-drop { 91 | position: absolute; 92 | z-index: 1; 93 | left: 0; 94 | top: 0; 95 | right: 0; 96 | bottom: 0; 97 | border-radius: 8px; 98 | border: 2px dashed rgba(64, 100, 138, 0.2); 99 | } 100 | 101 | .ui-area-drop.ui-area-drop-have { 102 | border-color: rgba(64, 100, 138, 0.4); 103 | } 104 | 105 | .ui-area-tip { 106 | text-align: center; 107 | } 108 | 109 | .ui-area-progress { 110 | padding: 5px 0 0; 111 | } 112 | 113 | .ui-area-waiting { 114 | padding: 3px 20px 0; 115 | line-height: 1.3; 116 | } 117 | 118 | .ui-area-waiting:before { 119 | display: block; 120 | text-align: center; 121 | padding-bottom: 8px; 122 | } 123 | 124 | .pie { 125 | position: relative; 126 | width: 80px; 127 | height: 80px; 128 | margin: 0 auto; 129 | border-radius: 50%; 130 | background-color: #bdf9db; 131 | } 132 | 133 | .pie.gt-50 { 134 | background-color: #11CD6E; 135 | border: solid 3px #00ac55; 136 | } 137 | 138 | .pie-progress { 139 | content: ""; 140 | position: absolute; 141 | border-radius: 50%; 142 | left: calc(50% - 40px); 143 | top: calc(50% - 40px); 144 | width: 80px; 145 | height: 80px; 146 | clip: rect(0, 80px, 80px, 40px); 147 | } 148 | 149 | .pie-progress .pie-progress-fill { 150 | content: ""; 151 | position: absolute; 152 | border-radius: 50%; 153 | left: calc(50% - 40px); 154 | top: calc(50% - 40px); 155 | width: 80px; 156 | height: 80px; 157 | clip: rect(0, 40px, 80px, 0); 158 | border: solid 3px #00ac55; 159 | background: #11CD6E; /*transition:transform 0.3s;*/ 160 | } 161 | 162 | .gt-50 .pie-progress { 163 | clip: rect(0, 40px, 80px, 0); 164 | } 165 | 166 | .gt-50 .pie-progress .pie-progress-fill { 167 | clip: rect(0, 80px, 80px, 40px); 168 | border: solid 3px #bdf9db; 169 | background: #bdf9db; 170 | } 171 | 172 | /*.pie-percents { content: ""; position: absolute; border-radius: 50%; left: calc(50% - 70px/2); top: calc(50% - 70px/2); width: 70px; height: 70px; background: #fff; text-align: center; display: table; } 173 | .pie-percents span { display: block; font-size: 2em; color: #11CD6E;} 174 | .pie-percents-wrapper { display: table-cell; vertical-align: middle;}*/ 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | English | [简体中文](./README-zh-CN.md) 2 | 3 | # hummingbird-h1 4 | 5 | 1. **Hummingbird** uses intelligent compression technology to reduce file size. It supports: jpg, png, webp, svg, gif, css, js, html, mp4, mov. You can set the size of the picture or video to be scaled equally at the same time as compression. Folder compression can be dragged and dropped. 6 | 2. **Hummingbird** can convert pictures in different formats, supports: png, webp, jpeg, jpg, gif, tiff, avif, heic, and can export pictures in different formats, supports: png, webp, jpeg, jpg, gif , tiff, avif, ico, icns (Apple system only) format pictures. 7 | 3. **Hummingbird** can extract audio from videos, delete audio from videos, convert videos to gif, and convert video formats, supporting mp4, mov, and avi. 8 | 4. One or more segments of audio can be cut, and can be converted to mp3, wav format. 9 | 10 | 11 | 12 | 13 | * jpg, png, webp, svg, gif, html will replace the current file after compression, Backup can be turned on in Settings, and hummingbird will back up the files to the `source` folder in the current directory. 14 | * A new file with .min will be generated after css, js, mp4 compression. 15 | 16 | ## Install 17 | 18 | ### Latest version 19 | 20 | #### Download from GitHub 21 | 22 | * **macOS**(Apple Silicon, arm64)After installation, you need to allow Hummingbird to launch in `System Settings` → `Security & Privacy`. 23 | * ~~**macOS**~~(Intel) 24 | * **Windows** (>=10) 25 | * **iOS** 26 | 27 | 28 | ### Old version v3.0.0 29 | 30 | Suitable for old versions of windows, win7, win8 31 | 32 | * **Windows**(v3.0.0,Google Drive) 33 | * **Windows**(v3.0.0,百度网盘) 34 | 35 | ## Help 36 | 37 | * [What is Apple Silicon?](https://arayofsunshine.dev/blog/apple-silicon) 38 | * [macOS App cannot be opened](https://arayofsunshine.dev/blog/macos-app-cannot-be-opened) 39 | 40 | 41 | ## Usage 42 | 43 | ### Reduce the file size 44 | 45 | #### jpg 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
BeforeAfter
41kB12kB
63 | 64 | #### png 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
BeforeAfter
28.9kB9.42kB
82 | 83 | #### svg 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
BeforeAfter
5.47kB3.55kB
101 | 102 | #### mov 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 |
BeforeAfter
1382.44MB37.95MB
116 | 117 | ### Crop audio 118 | 119 | audio 120 | 121 | 122 | ## Hidden functions 123 | 124 | Access hidden functions through the menu bar entrance. 125 | 126 | * **Hummingbird** can quickly obtain the Base64 encoding of files, supporting: png, webp, jpeg, jpg, gif, tiff, avi, svg, ttf, woff, woff2. 127 | * **Hummingbird** can view the history of compressed files. -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry file for the Hummingbird application 3 | */ 4 | import path from 'path'; 5 | import fs from 'fs/promises'; 6 | import { shell, ipcRenderer } from 'electron'; 7 | import { getUserHome } from './util.js'; 8 | import './index.css'; 9 | 10 | // Constants 11 | const LOG_FILE_NAME = 'hummingbird-log.txt'; 12 | const INITIAL_LOG_CONTENT = '----log----\n'; 13 | const GITHUB_ISSUES_URL = 'https://github.com/leibnizli/hummingbird/issues'; 14 | 15 | interface ShareData { 16 | count: number; 17 | size: number; 18 | } 19 | 20 | interface WindowState { 21 | shareCount: number; 22 | shareSize: number; 23 | } 24 | 25 | declare global { 26 | interface Window extends WindowState { 27 | shareCount: number; 28 | shareSize: number; 29 | } 30 | } 31 | 32 | // Initialize window state 33 | window.shareCount = 0; 34 | window.shareSize = 0; 35 | 36 | /** 37 | * Path to the application's log file 38 | */ 39 | const logPath = path.join(getUserHome(), LOG_FILE_NAME) 40 | /** 41 | * Initialize log file if it doesn't exist 42 | */ 43 | async function initializeLogFile(): Promise { 44 | try { 45 | await fs.access(logPath); 46 | console.log('Log file already exists.'); 47 | } catch { 48 | try { 49 | await fs.writeFile(logPath, INITIAL_LOG_CONTENT); 50 | console.log('Log file created successfully.'); 51 | } catch (error) { 52 | console.error('Error creating log file:', error); 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Prevent default drag and drop behavior 59 | */ 60 | function preventDefaultDragAndDrop(): void { 61 | const events = ['dragleave', 'drop', 'dragenter', 'dragover']; 62 | events.forEach(event => { 63 | document.addEventListener(event, (e) => e.preventDefault()); 64 | }); 65 | } 66 | 67 | /** 68 | * Handle button click events 69 | */ 70 | function handleButtonClick(event: MouseEvent): void { 71 | const target = event.target as HTMLElement; 72 | const button = target.closest('[id]') as HTMLElement; 73 | if (!button) return; 74 | 75 | const buttonActions: Record void> = { 76 | settings: () => ipcRenderer.send('open-settings-window'), 77 | convert: () => ipcRenderer.send('open-convert-window'), 78 | code: () => ipcRenderer.send('open-code-window'), 79 | video: () => ipcRenderer.send('open-video-window'), 80 | audio: () => ipcRenderer.send('open-audio-window'), 81 | log: () => shell.openPath(logPath), 82 | issues: () => shell.openExternal(GITHUB_ISSUES_URL), 83 | share: () => { 84 | // Commented out for future implementation 85 | // const shareText = `Hummingbird App has helped me process pictures ${window.shareCount} times and compressed the space ${(window.shareSize / (1024 * 1024)).toFixed(4)}M`; 86 | // shell.openExternal(`http://twitter.com/share?text=${shareText}&url=https://github.com/leibnizli/hummingbird`); 87 | }, 88 | minimized: () => ipcRenderer.send('main-minimized'), 89 | close: () => ipcRenderer.send('close-main-window'), 90 | }; 91 | 92 | const action = buttonActions[button.id]; 93 | if (action) { 94 | action(); 95 | } 96 | } 97 | 98 | /** 99 | * Handle share data updates from IPC 100 | */ 101 | function setupShareDataListener(): void { 102 | ipcRenderer.on('share-data', (_event, count: number, size: number) => { 103 | window.shareCount = count; 104 | window.shareSize = size; 105 | }); 106 | } 107 | 108 | /** 109 | * Check Windows version and apply UI adjustments if necessary 110 | */ 111 | async function checkWindowsVersion(): Promise { 112 | try { 113 | const ua = await navigator.userAgentData.getHighEntropyValues(['platformVersion']); 114 | if (navigator.userAgentData.platform === 'Windows') { 115 | const majorPlatformVersion = parseInt(ua.platformVersion.split('.')[0]); 116 | 117 | if (majorPlatformVersion >= 13) { 118 | console.log('Windows 11 or later'); 119 | } else if (majorPlatformVersion > 0) { 120 | console.log('Windows 10'); 121 | } else { 122 | console.log('Before Windows 10'); 123 | const uiApp = document.getElementById('ui-app'); 124 | if (uiApp) { 125 | uiApp.style.border = 'solid 1px #7d95ad'; 126 | } 127 | } 128 | } else { 129 | console.log('Not running on Windows'); 130 | } 131 | } catch (error) { 132 | console.error('Error checking Windows version:', error); 133 | } 134 | } 135 | 136 | /** 137 | * Initialize the application 138 | */ 139 | async function initialize(): Promise { 140 | await initializeLogFile(); 141 | preventDefaultDragAndDrop(); 142 | document.addEventListener('click', handleButtonClick); 143 | setupShareDataListener(); 144 | await checkWindowsVersion(); 145 | 146 | // Initialize app 147 | require('./app.js'); 148 | } 149 | 150 | // Start the application 151 | initialize().catch(error => { 152 | console.error('Failed to initialize application:', error); 153 | }); 154 | 155 | -------------------------------------------------------------------------------- /public/code.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { clipboard } = require('electron'); 3 | const {shell} = require("electron"); 4 | const { webUtils } = require('electron') 5 | 6 | // @ts-check 7 | 8 | /** 9 | * @typedef {Object} FileInfo 10 | * @property {string} path - The file path 11 | * @property {string} type - The file type 12 | */ 13 | 14 | /** 15 | * Service for managing file operations 16 | */ 17 | class FileService { 18 | /** @type {FileInfo[]} */ 19 | #files = []; 20 | 21 | /** 22 | * Updates the file list 23 | * @param {FileList} fileList - The list of files from input 24 | */ 25 | setFiles(fileList) { 26 | this.#files = Array.from(fileList).map((file) => ({ 27 | path: webUtils.getPathForFile(file), 28 | type: file.type 29 | })); 30 | } 31 | 32 | /** 33 | * Gets the current file list 34 | * @returns {FileInfo[]} 35 | */ 36 | getFiles() { 37 | return this.#files; 38 | } 39 | } 40 | 41 | /** 42 | * Service for handling clipboard operations 43 | */ 44 | class ClipboardService { 45 | /** 46 | * Writes text to clipboard 47 | * @param {string} text 48 | */ 49 | writeToClipboard(text) { 50 | clipboard.writeText(text); 51 | } 52 | } 53 | 54 | /** 55 | * Service for external operations 56 | */ 57 | class ExternalService { 58 | /** 59 | * Opens external URL 60 | * @param {string} url 61 | */ 62 | openExternal(url) { 63 | shell.openExternal(url); 64 | } 65 | } 66 | 67 | /** 68 | * UI notification service 69 | */ 70 | class NotificationService { 71 | /** @type {HTMLElement|null} */ 72 | #statusElement; 73 | 74 | constructor() { 75 | this.#statusElement = document.getElementById('status'); 76 | } 77 | 78 | /** 79 | * Shows a temporary notification 80 | * @param {string} message 81 | * @param {number} duration 82 | */ 83 | showTemporary(message, duration = 750) { 84 | if (!this.#statusElement) return; 85 | 86 | this.#statusElement.textContent = message; 87 | setTimeout(() => { 88 | if (this.#statusElement) { 89 | this.#statusElement.textContent = ''; 90 | } 91 | }, duration); 92 | } 93 | } 94 | 95 | /** 96 | * Main application controller 97 | */ 98 | class AppController { 99 | /** @type {FileService} */ 100 | #fileService; 101 | /** @type {ClipboardService} */ 102 | #clipboardService; 103 | /** @type {ExternalService} */ 104 | #externalService; 105 | /** @type {NotificationService} */ 106 | #notificationService; 107 | 108 | constructor() { 109 | this.#fileService = new FileService(); 110 | this.#clipboardService = new ClipboardService(); 111 | this.#externalService = new ExternalService(); 112 | this.#notificationService = new NotificationService(); 113 | 114 | this.#initializeEventListeners(); 115 | } 116 | 117 | /** 118 | * Initializes all event listeners 119 | * @private 120 | */ 121 | #initializeEventListeners() { 122 | this.#initializeFileInput(); 123 | this.#initializeButtonHandlers(); 124 | } 125 | 126 | /** 127 | * Initializes file input handler 128 | * @private 129 | */ 130 | #initializeFileInput() { 131 | const fileInput = document.getElementById('file'); 132 | if (!fileInput) return; 133 | 134 | fileInput.addEventListener('change', (e) => { 135 | // @ts-ignore 136 | const files = e.target.files; 137 | if (files) { 138 | this.#fileService.setFiles(files); 139 | } 140 | }); 141 | } 142 | 143 | /** 144 | * Initializes button click handlers 145 | * @private 146 | */ 147 | #initializeButtonHandlers() { 148 | document.addEventListener('click', (e) => { 149 | const button = e.target.closest('[id]'); 150 | if (!button) return; 151 | 152 | const handlers = { 153 | 'getBase64': () => this.#handleBase64Conversion(), 154 | 'how': () => this.#externalService.openExternal('https://arayofsunshine.dev/blog/base64'), 155 | 'gadgets': () => this.#externalService.openExternal('https://gadgets.arayofsunshine.dev') 156 | }; 157 | 158 | // @ts-ignore 159 | const handler = handlers[button.id]; 160 | if (handler) { 161 | handler(); 162 | } 163 | }); 164 | } 165 | 166 | /** 167 | * Handles base64 conversion and clipboard copy 168 | * @private 169 | */ 170 | #handleBase64Conversion() { 171 | const files = this.#fileService.getFiles(); 172 | if (files.length === 0) return; 173 | 174 | try { 175 | const pngData = fs.readFileSync(files[0].path); 176 | const base64Data = pngData.toString('base64'); 177 | this.#clipboardService.writeToClipboard(base64Data); 178 | this.#notificationService.showTemporary('Copied.'); 179 | } catch (error) { 180 | console.error('Failed to convert file:', error); 181 | this.#notificationService.showTemporary('Error converting file.'); 182 | } 183 | } 184 | } 185 | 186 | // Initialize the application 187 | new AppController(); 188 | -------------------------------------------------------------------------------- /demo/svg-before.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/convert.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { app, clipboard } = require('electron'); 3 | const { webUtils } = require('electron') 4 | const { promisify } = require('util'); 5 | const heicConvert = require('heic-convert'); 6 | const path = require("path"); 7 | const {execSync} = require('child_process'); 8 | const {shell} = require("electron"); 9 | const sharp = require('sharp'); 10 | const toIco = require('to-ico'); 11 | var files = []; 12 | var checkedData = []; 13 | 14 | function openFolder(path) { 15 | shell.openPath(path) 16 | } 17 | 18 | // 文件选择事件 19 | const fileInput = document.getElementById('file'); 20 | if (fileInput) { 21 | fileInput.addEventListener('change', function(e) { 22 | console.log(this.files); 23 | const convertLog = document.getElementById('convertLog'); 24 | if (convertLog) { 25 | convertLog.innerHTML = ''; 26 | } 27 | files = Array.from(this.files).map((ele, i) => { 28 | return { 29 | path: webUtils.getPathForFile(ele), 30 | type: ele.type 31 | } 32 | }); 33 | files = files.filter((ele) => { 34 | return ele.type !== "" 35 | }); 36 | }); 37 | } 38 | 39 | // 按钮点击事件处理 40 | document.addEventListener('click', (e) => { 41 | const button = e.target.closest('[id]'); 42 | if (!button) return; 43 | 44 | switch (button.id) { 45 | case 'export': 46 | checkedData = []; 47 | if (files.length > 0) { 48 | document.querySelectorAll("input[type='checkbox']").forEach((ele) => { 49 | if (ele.checked) { 50 | checkedData.push(ele.value); 51 | } 52 | }); 53 | Convert(); 54 | } 55 | break; 56 | } 57 | }); 58 | 59 | function writeFile(targetPath, buffer) { 60 | fs.writeFile(targetPath, buffer, function (e) { 61 | console.log(`Image has been ${targetPath}`); 62 | }); 63 | } 64 | 65 | function generateFavicon(sourcePath, destPath) { 66 | const image = fs.readFileSync(sourcePath); 67 | const convertLog = document.getElementById('convertLog'); 68 | 69 | toIco([image], { 70 | sizes: [16, 24, 32, 48, 64, 128, 256], 71 | resize: true 72 | }).then(result => { 73 | fs.writeFileSync(destPath, result); 74 | console.log(`Image has been ${destPath}`); 75 | }).catch((err) => { 76 | if (convertLog) { 77 | convertLog.innerHTML = `${err}`; 78 | } 79 | }); 80 | } 81 | 82 | function Convert() { 83 | if (checkedData.length === 0) return; 84 | if (files.length === 0) return; 85 | console.log(files,'files') 86 | const convertLog = document.getElementById('convertLog'); 87 | 88 | files.forEach((ele, i) => { 89 | let fileDirname = path.dirname(ele.path); 90 | let extension = path.extname(ele.path); 91 | let file = path.basename(ele.path, extension); 92 | checkedData.forEach((checkEle) => { 93 | let targetPath = path.join(fileDirname, `${file}.${checkEle}`); 94 | console.log(checkEle); 95 | if (ele.type.indexOf(checkEle) > -1) return; 96 | if (ele.type === "image/heic") { 97 | switch (checkEle) { 98 | case "jpeg": 99 | (async () => { 100 | const inputBuffer = await promisify(fs.readFile)(ele.path); 101 | const outputBuffer = await heicConvert({ 102 | buffer: inputBuffer, // the HEIC file buffer 103 | format: 'JPEG', // output format 104 | }); 105 | await promisify(fs.writeFile)(targetPath, outputBuffer); 106 | })(); 107 | break; 108 | case "png": 109 | (async () => { 110 | const inputBuffer = await promisify(fs.readFile)(ele.path); 111 | const outputBuffer = await heicConvert({ 112 | buffer: inputBuffer, // the HEIC file buffer 113 | format: 'PNG', // output format 114 | }); 115 | await promisify(fs.writeFile)(targetPath, outputBuffer); 116 | })(); 117 | break; 118 | default: 119 | break; 120 | } 121 | } else { 122 | switch (checkEle) { 123 | case "jpeg": 124 | sharp(ele.path) 125 | .jpeg({ 126 | quality: 100, 127 | chromaSubsampling: '4:4:4' 128 | }) 129 | .toBuffer(function (err, buffer) { 130 | writeFile(targetPath, buffer); 131 | }); 132 | break; 133 | case "png": 134 | sharp(ele.path) 135 | .png() 136 | .toBuffer(function (err, buffer) { 137 | writeFile(targetPath, buffer); 138 | }); 139 | break; 140 | case "webp": 141 | sharp(ele.path) 142 | .webp({lossless: true}) 143 | .toBuffer(function (err, buffer) { 144 | writeFile(targetPath, buffer); 145 | }); 146 | break; 147 | case "gif": 148 | sharp(ele.path) 149 | .gif() 150 | .toBuffer(function (err, buffer) { 151 | writeFile(targetPath, buffer); 152 | }); 153 | break; 154 | case "tiff": 155 | sharp(ele.path) 156 | .tiff({ 157 | compression: 'lzw', 158 | bitdepth: 1 159 | }) 160 | .toBuffer(function (err, buffer) { 161 | writeFile(targetPath, buffer); 162 | }); 163 | break; 164 | case "avif": 165 | sharp(ele.path) 166 | .avif({effort: 2}) 167 | .toBuffer(function (err, buffer) { 168 | writeFile(targetPath, buffer); 169 | }); 170 | break; 171 | case "ico": 172 | if (ele.type === "image/png") { 173 | generateFavicon(ele.path, targetPath) 174 | } 175 | break; 176 | case "icns": 177 | if (ele.type === "image/png") { 178 | const iconsetFolderPath = path.join(fileDirname, 'icons.iconset'); 179 | if (!fs.existsSync(iconsetFolderPath)) { 180 | fs.mkdirSync(iconsetFolderPath); 181 | } 182 | try { 183 | // 执行每个命令 184 | execSync(`sips -z 16 16 "${ele.path}" -o "${iconsetFolderPath}/icon_16x16.png"`); 185 | execSync(`sips -z 32 32 "${ele.path}" -o "${iconsetFolderPath}/icon_16x16@2x.png"`); 186 | execSync(`sips -z 32 32 "${ele.path}" -o "${iconsetFolderPath}/icon_32x32.png"`); 187 | execSync(`sips -z 64 64 "${ele.path}" -o "${iconsetFolderPath}/icon_32x32@2x.png"`); 188 | execSync(`sips -z 128 128 "${ele.path}" -o "${iconsetFolderPath}/icon_128x128.png"`); 189 | execSync(`sips -z 256 256 "${ele.path}" -o "${iconsetFolderPath}/icon_128x128@2x.png"`); 190 | execSync(`sips -z 256 256 "${ele.path}" -o "${iconsetFolderPath}/icon_256x256.png"`); 191 | execSync(`sips -z 512 512 "${ele.path}" -o "${iconsetFolderPath}/icon_256x256@2x.png"`); 192 | execSync(`sips -z 512 512 "${ele.path}" -o "${iconsetFolderPath}/icon_512x512.png"`); 193 | execSync(`sips -z 1024 1024 "${ele.path}" -o "${iconsetFolderPath}/icon_512x512@2x.png"`); 194 | execSync(`iconutil -c icns "${iconsetFolderPath}" -o "${targetPath}"`); 195 | execSync(`rm -r "${iconsetFolderPath}"`); 196 | 197 | console.log('All commands executed successfully'); 198 | } catch (error) { 199 | if (convertLog) { 200 | convertLog.innerHTML = `Error executing command: ${error.message}`; 201 | } 202 | console.error(`Error executing command: ${error.message}`); 203 | } 204 | } 205 | break; 206 | default: 207 | break; 208 | } 209 | } 210 | }) 211 | if (i === 0) { 212 | openFolder(fileDirname); 213 | } 214 | }); 215 | } 216 | -------------------------------------------------------------------------------- /public/video.js: -------------------------------------------------------------------------------- 1 | // Constants and Types 2 | const SUPPORTED_FORMATS = { 3 | MP4: { 4 | extension: 'mp4', 5 | options: { 6 | vcodec: 'h264' 7 | } 8 | }, 9 | MP3: { 10 | extension: 'mp3', 11 | options: { 12 | ab: '192k' 13 | } 14 | }, 15 | MOV: { 16 | extension: 'mov', 17 | options: {} 18 | }, 19 | AVI: { 20 | extension: 'avi', 21 | options: {} 22 | }, 23 | GIF: { 24 | extension: 'gif', 25 | options: {} 26 | } 27 | }; 28 | 29 | // Core dependencies 30 | const fs = require('fs'); 31 | const path = require('path'); 32 | const { clipboard, shell } = require('electron'); 33 | const ffmpegStatic = require('ffmpeg-static'); 34 | const ffmpeg = require('fluent-ffmpeg'); 35 | const { webUtils } = require('electron'); 36 | 37 | // FFmpeg configuration 38 | ffmpeg.setFfmpegPath(ffmpegStatic); 39 | 40 | /** 41 | * @typedef {Object} FileInfo 42 | * @property {string} path - File path 43 | * @property {string} type - File MIME type 44 | */ 45 | 46 | /** 47 | * VideoProcessor class handles all video processing operations 48 | */ 49 | class VideoProcessor { 50 | constructor() { 51 | this.files = []; 52 | this.checkedFormats = []; 53 | this.statusElements = { 54 | main: document.getElementById('status'), 55 | mp3: document.getElementById('status-mp3'), 56 | delete: document.getElementById('status-delete') 57 | }; 58 | } 59 | 60 | /** 61 | * Initialize event listeners 62 | */ 63 | init() { 64 | this._initFileInput(); 65 | this._initButtonListeners(); 66 | } 67 | 68 | /** 69 | * @param {string} filePath - Path to open in system file explorer 70 | */ 71 | static openFolder(filePath) { 72 | shell.openPath(filePath); 73 | } 74 | 75 | /** 76 | * @private 77 | * Updates status element with progress or completion message 78 | */ 79 | _updateStatus(statusElement, message) { 80 | if (statusElement) { 81 | statusElement.textContent = message; 82 | } 83 | } 84 | 85 | /** 86 | * @private 87 | * Handles file conversion with FFmpeg 88 | */ 89 | async _processVideo(inputPath, outputPath, options = {}, statusElement) { 90 | return new Promise((resolve, reject) => { 91 | const command = ffmpeg() 92 | .input(inputPath) 93 | .on('progress', (progress) => { 94 | if (progress.percent) { 95 | this._updateStatus(statusElement, `Processing: ${Math.floor(progress.percent)}% done`); 96 | } 97 | }) 98 | .on('end', () => { 99 | this._updateStatus(statusElement, 'finished'); 100 | resolve(); 101 | }) 102 | .on('error', (error) => { 103 | this._updateStatus(statusElement, 'error'); 104 | reject(error); 105 | }); 106 | 107 | // Apply additional options 108 | Object.entries(options).forEach(([key, value]) => { 109 | command.outputOptions(`-${key}`, value); 110 | }); 111 | 112 | command.saveToFile(outputPath); 113 | }); 114 | } 115 | 116 | /** 117 | * @private 118 | * Initialize file input handler 119 | */ 120 | _initFileInput() { 121 | const fileInput = document.getElementById('file'); 122 | if (fileInput) { 123 | fileInput.addEventListener('change', (e) => { 124 | this.files = Array.from(e.target.files).map(file => ({ 125 | path: webUtils.getPathForFile(file), 126 | type: file.type 127 | })); 128 | }); 129 | } 130 | } 131 | 132 | /** 133 | * @private 134 | * Initialize button click handlers 135 | */ 136 | _initButtonListeners() { 137 | document.addEventListener('click', (e) => { 138 | const button = e.target.closest('[id]'); 139 | console.log(button) 140 | if (!button) return; 141 | 142 | const handlers = { 143 | 'mp3': () => this._handleMp3Conversion(), 144 | 'delete': () => this._handleAudioDeletion(), 145 | 'export': () => this._handleFormatConversion() 146 | }; 147 | 148 | const handler = handlers[button.id]; 149 | if (handler) { 150 | handler(); 151 | } 152 | }); 153 | } 154 | 155 | /** 156 | * @private 157 | * Handles conversion of video/audio files to MP3 format 158 | */ 159 | async _handleMp3Conversion() { 160 | if (this.files.length === 0) return; 161 | 162 | for (const [index, file] of this.files.entries()) { 163 | const fileDirname = path.dirname(file.path); 164 | const extension = path.extname(file.path); 165 | const fileName = path.basename(file.path, extension); 166 | const targetPath = path.join(fileDirname, `${fileName}.mp3`); 167 | 168 | try { 169 | await this._processVideo( 170 | file.path, 171 | targetPath, 172 | SUPPORTED_FORMATS.MP3.options, 173 | this.statusElements.mp3 174 | ); 175 | 176 | if (index === 0) { 177 | VideoProcessor.openFolder(fileDirname); 178 | } 179 | } catch (error) { 180 | console.error('MP3 conversion failed:', error); 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * @private 187 | * Handles removal of audio from video files 188 | */ 189 | async _handleAudioDeletion() { 190 | if (this.files.length === 0) return; 191 | 192 | for (const [index, file] of this.files.entries()) { 193 | const fileDirname = path.dirname(file.path); 194 | const extension = path.extname(file.path); 195 | const fileName = path.basename(file.path, extension); 196 | const targetPath = path.join(fileDirname, `${fileName}_no_audio${extension}`); 197 | 198 | try { 199 | const command = ffmpeg() 200 | .input(file.path) 201 | .noAudio() 202 | .outputOptions('-codec', 'copy') 203 | .on('progress', (progress) => { 204 | if (progress.percent) { 205 | this._updateStatus( 206 | this.statusElements.delete, 207 | `Processing: ${Math.floor(progress.percent)}% done` 208 | ); 209 | } 210 | }) 211 | .on('end', () => { 212 | this._updateStatus(this.statusElements.delete, 'finished'); 213 | }) 214 | .on('error', (error) => { 215 | this._updateStatus(this.statusElements.delete, 'error'); 216 | console.error('Audio removal failed:', error); 217 | }); 218 | 219 | command.saveToFile(targetPath); 220 | 221 | if (index === 0) { 222 | VideoProcessor.openFolder(fileDirname); 223 | } 224 | } catch (error) { 225 | console.error('Audio removal failed:', error); 226 | } 227 | } 228 | } 229 | 230 | /** 231 | * @private 232 | * Collects selected format options from checkboxes 233 | * @returns {string[]} Array of selected format extensions 234 | */ 235 | _getSelectedFormats() { 236 | const checkedFormats = []; 237 | document.querySelectorAll("input[type='checkbox']").forEach((element) => { 238 | if (element.checked) { 239 | checkedFormats.push(element.value); 240 | } 241 | }); 242 | return checkedFormats; 243 | } 244 | 245 | /** 246 | * @private 247 | * Handles conversion of files to selected formats 248 | */ 249 | async _handleFormatConversion() { 250 | if (this.files.length === 0) return; 251 | 252 | const selectedFormats = this._getSelectedFormats(); 253 | if (selectedFormats.length === 0) return; 254 | 255 | for (const [fileIndex, file] of this.files.entries()) { 256 | const fileDirname = path.dirname(file.path); 257 | const extension = path.extname(file.path); 258 | const fileName = path.basename(file.path, extension); 259 | 260 | for (const format of selectedFormats) { 261 | // Skip if source file is already in target format 262 | if (file.type.includes(format)) continue; 263 | 264 | const targetPath = path.join(fileDirname, `${fileName}.${format}`); 265 | const formatKey = format.toUpperCase(); 266 | const options = SUPPORTED_FORMATS[formatKey]?.options || {}; 267 | 268 | try { 269 | await this._processVideo( 270 | file.path, 271 | targetPath, 272 | options, 273 | this.statusElements.main 274 | ); 275 | } catch (error) { 276 | console.error(`Conversion to ${format} failed:`, error); 277 | } 278 | } 279 | 280 | if (fileIndex === 0) { 281 | VideoProcessor.openFolder(fileDirname); 282 | } 283 | } 284 | } 285 | } 286 | 287 | // Initialize the processor 288 | const processor = new VideoProcessor(); 289 | processor.init(); 290 | -------------------------------------------------------------------------------- /src/audio.js: -------------------------------------------------------------------------------- 1 | const WaveSurfer = require('wavesurfer.js'); 2 | const TimelinePlugin = require('wavesurfer.js/dist/plugins/timeline.esm.js'); 3 | const Minimap = require('wavesurfer.js/dist/plugins/minimap.esm.js'); 4 | const RegionsPlugin = require('wavesurfer.js/dist/plugins/regions.esm.js'); 5 | const os = require('os'); 6 | const fs = require("fs"); 7 | const {shell} = require("electron"); 8 | const { webUtils } = require('electron') 9 | const ffmpegStatic = require('ffmpeg-static'); 10 | const ffmpeg = require('fluent-ffmpeg'); 11 | const path = require("path"); 12 | 13 | // Tell fluent-ffmpeg where it can find FFmpeg 14 | ffmpeg.setFfmpegPath(ffmpegStatic); 15 | 16 | const getDesktopOrHomeDir = () => { 17 | const homeDir = path.resolve(os.homedir()) 18 | const desktopDir = path.resolve(os.homedir(), 'Desktop') 19 | if (!fs.existsSync(desktopDir)) { 20 | return homeDir; 21 | } 22 | return desktopDir; 23 | } 24 | 25 | let files = [{ 26 | path: path.join(__dirname, '/hummingbird-test-audio.mp3'), 27 | type: 'audio/mpeg' 28 | }]; 29 | let checkedData = []; 30 | const status = document.getElementById('status'); 31 | const audioCut = document.getElementById('audioCut'); 32 | 33 | function openFolder(path) { 34 | shell.openPath(path) 35 | } 36 | 37 | const wavesurfer = WaveSurfer.create({ 38 | container: '#waveform', 39 | scrollParent: true, 40 | waveColor: '#4F4A85', 41 | progressColor: '#383351', 42 | url: './hummingbird-test-audio.mp3', 43 | plugins: [ 44 | TimelinePlugin.create(), 45 | Minimap.create({ 46 | height: 20, 47 | waveColor: '#ddd', 48 | progressColor: '#999', 49 | // the Minimap takes all the same options as the WaveSurfer itself 50 | }) 51 | ], 52 | }); 53 | wavesurfer.on('interaction', () => { 54 | wavesurfer.play(); 55 | }); 56 | wavesurfer.on('finish', () => { 57 | if (loop) { 58 | 59 | } else { 60 | wavesurfer.setTime(0); 61 | } 62 | 63 | }); 64 | // Initialize the Regions plugin 65 | const wsRegions = wavesurfer.registerPlugin(RegionsPlugin.create()); 66 | 67 | wavesurfer.on('decode', () => { 68 | // Regions 69 | // wsRegions.addRegion({ 70 | // start: 0, 71 | // end: 10, 72 | // content: '', 73 | // color: 'rgb(0 254 143 / 0.5)', 74 | // resize: true, 75 | // }); 76 | }); 77 | wsRegions.enableDragSelection({ 78 | color: 'rgb(0 254 143 / 0.3)', 79 | }); 80 | wsRegions.on('region-updated', (region) => { 81 | console.log('Updated region', region) 82 | }); 83 | // Update the zoom level on slider change 84 | wavesurfer.once('decode', () => { 85 | const slider = document.querySelector('input[type="range"]') 86 | slider.addEventListener('input', (e) => { 87 | const minPxPerSec = e.target.valueAsNumber 88 | wavesurfer.zoom(minPxPerSec) 89 | }); 90 | }); 91 | // Give regions a random color when they are created 92 | const random = (min, max) => Math.random() * (max - min) + min 93 | const randomColor = () => `rgba(${random(0, 255)}, ${random(0, 255)}, ${random(0, 255)}, 0.5)` 94 | // Loop a region on click 95 | let loop = false; 96 | // Toggle looping with a checkbox 97 | document.querySelector('input[type="checkbox"]').onclick = (e) => { 98 | loop = e.target.checked 99 | } 100 | 101 | let activeRegion = null; 102 | wsRegions.on('region-in', (region) => { 103 | console.log('region-in', region); 104 | activeRegion = region; 105 | }); 106 | wsRegions.on('region-out', (region) => { 107 | console.log('region-out', region) 108 | if (activeRegion === region) { 109 | if (loop) { 110 | region.play(); 111 | } else { 112 | activeRegion = null; 113 | } 114 | } 115 | }); 116 | wsRegions.on('region-clicked', (region, e) => { 117 | e.stopPropagation() // prevent triggering a click on the waveform 118 | activeRegion = region; 119 | region.play(); 120 | wsRegions.getRegions().forEach((el, i) => { 121 | if (el.start === region.start) { 122 | el.setOptions({color: 'rgb(0 254 143 / 0.8)'}); 123 | } else { 124 | el.setOptions({color: 'rgb(0 254 143 / 0.3)'}); 125 | } 126 | }); 127 | }); 128 | // Reset the active region when the user clicks anywhere in the waveform 129 | wavesurfer.on('interaction', () => { 130 | activeRegion = null; 131 | }); 132 | document.addEventListener('keydown', function (e) { 133 | e.preventDefault(); 134 | if (e.key === ' ') { 135 | console.log(e.key) 136 | // 在这里处理空格键按下事件 137 | console.log('Space key pressed'); 138 | if (wavesurfer.isPlaying()) { 139 | wavesurfer.pause(); 140 | } else { 141 | wavesurfer.play(); 142 | } 143 | } 144 | if (e.key === 'Backspace') { 145 | console.log(e.key) 146 | if (activeRegion) { 147 | activeRegion.remove(); 148 | } 149 | // wsRegions.clearRegions(); 150 | } 151 | }); 152 | document.getElementById('file').addEventListener('change', function(e) { 153 | console.log(this.files) 154 | files = Array.from(this.files).map((ele, i) => { 155 | return { 156 | path: webUtils.getPathForFile(ele), 157 | type: ele.type 158 | } 159 | }); 160 | wsRegions.clearRegions(); 161 | if (files.length === 1) { 162 | wavesurfer.load(`file://${files[0].path}`); 163 | audioCut.style.display = 'block'; 164 | } 165 | if (files.length > 1) { 166 | audioCut.style.display = 'none'; 167 | } 168 | }); 169 | document.getElementById('export').addEventListener('click', function(e) { 170 | checkedData = []; 171 | if (files.length > 0) { 172 | document.querySelectorAll("input[type='checkbox']").forEach((ele) => { 173 | if (ele.checked) { 174 | checkedData.push(ele.value); 175 | } 176 | }); 177 | if (checkedData.length === 0) return; 178 | Convert(); 179 | } 180 | }); 181 | 182 | function Convert() { 183 | const segments = []; 184 | wsRegions.getRegions().forEach((el, i) => { 185 | segments.push([el.start, el.end]); 186 | }); 187 | if (segments.length > 0) { 188 | const ele = files[0]; 189 | let fileDirname = path.dirname(ele.path); 190 | let extension = path.extname(ele.path); 191 | let file = path.basename(ele.path, extension); 192 | if (file === 'hummingbird-test-audio') { 193 | fileDirname = getDesktopOrHomeDir(); 194 | } 195 | checkedData.forEach((checkEle) => { 196 | switch (checkEle) { 197 | case "mp3": 198 | segments.forEach((seg) => { 199 | console.log(seg); 200 | ffmpeg() 201 | .input(ele.path) 202 | .outputOptions(`-ss`, `${seg[0]}`, `-to`, `${seg[1]}`) 203 | .on('progress', (progress) => { 204 | if (progress.percent) { 205 | status.textContent = `Processing: ${Math.floor(progress.percent)}% done`; 206 | } 207 | }) 208 | .saveToFile(path.join(fileDirname, `${file}_output_${seg[0]}.${checkEle}`)) 209 | .on('end', () => { 210 | status.textContent = 'finished'; 211 | console.log('FFmpeg has finished.'); 212 | }) 213 | .on('error', (error) => { 214 | status.textContent = 'error'; 215 | }); 216 | }); 217 | break; 218 | case "wav": 219 | segments.forEach((seg) => { 220 | console.log(seg); 221 | ffmpeg() 222 | .input(ele.path) 223 | .outputOptions(`-ss`, `${seg[0]}`, `-to`, `${seg[1]}`) 224 | .on('progress', (progress) => { 225 | if (progress.percent) { 226 | status.textContent = `Processing: ${Math.floor(progress.percent)}% done`; 227 | } 228 | }) 229 | .saveToFile(path.join(fileDirname, `${file}_output_${seg[0]}.${checkEle}`)) 230 | .on('end', () => { 231 | status.textContent = 'finished'; 232 | console.log('FFmpeg has finished.'); 233 | openFolder(fileDirname); 234 | }) 235 | .on('error', (error) => { 236 | status.textContent = 'error'; 237 | }); 238 | }); 239 | break; 240 | default: 241 | break; 242 | } 243 | }); 244 | openFolder(fileDirname); 245 | } else { 246 | files.forEach((ele, i) => { 247 | let fileDirname = path.dirname(ele.path); 248 | let extension = path.extname(ele.path); 249 | let file = path.basename(ele.path, extension); 250 | if (file === 'hummingbird-test-audio') { 251 | fileDirname = getDesktopOrHomeDir(); 252 | } 253 | checkedData.forEach((checkEle) => { 254 | let targetPath = path.join(fileDirname, `${file}.${checkEle}`); 255 | switch (checkEle) { 256 | case "mp3": 257 | if (ele.type.indexOf('mpeg') > -1) return; 258 | ffmpeg() 259 | .input(ele.path) 260 | .on('progress', (progress) => { 261 | if (progress.percent) { 262 | status.textContent = `Processing: ${Math.floor(progress.percent)}% done`; 263 | } 264 | }) 265 | .saveToFile(targetPath) 266 | .on('end', () => { 267 | status.textContent = 'finished'; 268 | console.log('FFmpeg has finished.'); 269 | }) 270 | .on('error', (error) => { 271 | status.textContent = 'error'; 272 | }); 273 | break; 274 | case "wav": 275 | if (ele.type.indexOf('wav') > -1) return; 276 | ffmpeg() 277 | .input(ele.path) 278 | .on('progress', (progress) => { 279 | if (progress.percent) { 280 | status.textContent = `Processing: ${Math.floor(progress.percent)}% done`; 281 | } 282 | }) 283 | .saveToFile(targetPath) 284 | .on('end', () => { 285 | status.textContent = 'finished'; 286 | console.log('FFmpeg has finished.'); 287 | }) 288 | .on('error', (error) => { 289 | status.textContent = 'error'; 290 | }); 291 | break; 292 | default: 293 | break; 294 | } 295 | }); 296 | if (i === 0) { 297 | openFolder(fileDirname); 298 | } 299 | }); 300 | } 301 | 302 | } 303 | 304 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const {app, BrowserWindow, ipcMain, dialog, shell, Menu} = require('electron'); 2 | const {autoUpdater} = require('electron-updater'); 3 | const path = require('path') 4 | const log = require('electron-log'); 5 | const configuration = require("./configuration"); 6 | const express = require('express') 7 | const {getUserHome} = require("./src/util.js"); 8 | 9 | // Configure electron-log for production 10 | log.transports.file.level = 'info'; 11 | log.transports.console.level = 'info'; 12 | 13 | // Override console methods to use electron-log in production 14 | if (app.isPackaged) { 15 | console.log = log.log; 16 | console.error = log.error; 17 | console.warn = log.warn; 18 | console.info = log.info; 19 | } 20 | if (!configuration.get('jpg')) { 21 | configuration.set('jpg', 80); 22 | } 23 | if (!configuration.get('webp')) { 24 | configuration.set('webp', 85); 25 | } 26 | if (!configuration.get('count')) { 27 | configuration.set('count', 0); 28 | } 29 | if (!configuration.get('size')) { 30 | configuration.set('size', 0); 31 | } 32 | if (!configuration.get('backup')) { 33 | configuration.set('backup', false); 34 | } 35 | if (!configuration.get('port')) { 36 | configuration.set('port', 3373); 37 | } 38 | if (!configuration.get('progressive')) { 39 | configuration.set('progressive', true); 40 | } 41 | if (!configuration.get('png')) { 42 | configuration.set('png', [0.6, 0.85]); 43 | } 44 | 45 | let initialPort = configuration.get('port') || 3373 46 | 47 | const server = express() 48 | // 指定静态文件目录 49 | server.use(express.static(__dirname + '/public')); 50 | 51 | const net = require('net'); 52 | 53 | function startServer(port) { 54 | 55 | server.listen(port, () => { 56 | initialPort = port; 57 | console.log('Server started on port ' + port); 58 | }); 59 | 60 | server.on('error', (err) => { 61 | if (err.code === 'EADDRINUSE') { 62 | console.log(`Port ${port} is in use, trying another port...`); 63 | startServer(port + 1); 64 | } else { 65 | console.error('Server error:', err); 66 | } 67 | }); 68 | } 69 | 70 | function checkPort(port, callback) { 71 | const tester = net.createServer() 72 | .once('error', err => (err.code === 'EADDRINUSE' ? callback(true) : callback(false))) 73 | .once('listening', () => tester.once('close', () => callback(false)).close()) 74 | .listen(port); 75 | } 76 | 77 | checkPort(initialPort, (isBusy) => { 78 | if (isBusy) { 79 | console.log(`Port ${initialPort} is in use, trying another port...`); 80 | startServer(initialPort + 1); 81 | } else { 82 | startServer(initialPort); 83 | } 84 | }); 85 | 86 | const isMac = process.platform === 'darwin' 87 | 88 | autoUpdater.logger = log; 89 | autoUpdater.logger.transports.file.level = 'info'; 90 | log.info('App starting...'); 91 | 92 | let settingsWindow = null, 93 | mainWindow = null, 94 | codeWindow = null, 95 | videoWindow = null, 96 | audioWindow = null, 97 | convertWindow = null; 98 | app.on('window-all-closed', function () { 99 | // 在 OS X 上,通常用户在明确地按下 Cmd + Q 之前 100 | // 应用会保持活动状态 101 | if (process.platform != 'darwin') { 102 | } 103 | app.quit(); 104 | }); 105 | 106 | console.log('__dirname', __dirname, path.join(__dirname, 'locales')); 107 | // 当 Electron 完成了初始化并且准备创建浏览器窗口的时候 108 | // 这个方法就被调用 109 | app.on('ready', function () { 110 | // 创建浏览器窗口。 111 | mainWindow = new BrowserWindow({ 112 | icon: './src/images/icon.png', 113 | title: 'Hummingbird', 114 | width: app.isPackaged?312:812, 115 | height: app.isPackaged?260:760, 116 | frame: false, 117 | resizable: false, 118 | webPreferences: { 119 | sandbox: false, 120 | enableRemoteModule: true, 121 | nodeIntegration: true, 122 | nodeIntegrationInWorker: true, 123 | contextIsolation: false 124 | } 125 | }); 126 | let locate = ""; 127 | if (app.getLocale() === "zh-CN") { 128 | locate = "-zh-CN"; 129 | } 130 | // 打开开发工具(仅在开发环境) 131 | if (!app.isPackaged) { 132 | mainWindow.openDevTools(); 133 | } 134 | // 加载应用的 index.html 135 | mainWindow.loadURL(`http://localhost:${initialPort}` + `/index${locate}.html`); 136 | // 当 window 被关闭,这个事件会被发出 137 | mainWindow.on('closed', function () { 138 | // 取消引用 window 对象,如果你的应用支持多窗口的话,通常会把多个 window 对象存放在一个数组里面,但这次不是。 139 | mainWindow = null; 140 | }); 141 | mainWindow.webContents.on('did-finish-load', function () { 142 | mainWindow.webContents.send('appPath', app.getAppPath()); 143 | mainWindow.webContents.send('quality', configuration.get('jpg'), configuration.get('webp')); 144 | mainWindow.webContents.send('png', configuration.get('png')); 145 | mainWindow.webContents.send('progressive', configuration.get('progressive')); 146 | mainWindow.webContents.send('share-data', configuration.get('count'), configuration.get('size')); 147 | mainWindow.webContents.send('backup', configuration.get('backup')); 148 | }); 149 | mainWindow.once('ready-to-show', () => { 150 | autoUpdater.checkForUpdatesAndNotify(); 151 | }); 152 | }); 153 | ipcMain.on('close-main-window', function () { 154 | app.quit(); 155 | }); 156 | ipcMain.on('main-minimized', function () { 157 | mainWindow.minimize(); 158 | }); 159 | ipcMain.on('open-convert-window', function () { 160 | if (convertWindow) { 161 | return; 162 | } 163 | convertWindow = new BrowserWindow({ 164 | icon: './src/images/icon.png', 165 | title: 'Convert image format', 166 | width: 480, 167 | height: 320, 168 | frame: true, 169 | resizable: true, 170 | webPreferences: { 171 | sandbox: false, 172 | enableRemoteModule: true, 173 | nodeIntegration: true, 174 | nodeIntegrationInWorker: true, 175 | contextIsolation: false 176 | } 177 | }); 178 | let locate = ""; 179 | if (app.getLocale() === "zh-CN") { 180 | locate = "-zh-CN"; 181 | } 182 | // 加载应用的 index.html 183 | convertWindow.loadURL(`http://localhost:${initialPort}` + `/convert${locate}.html`); 184 | 185 | // 打开开发工具 186 | // convertWindow.openDevTools(); 187 | // 当 window 被关闭,这个事件会被发出 188 | convertWindow.on('closed', function () { 189 | // 取消引用 window 对象,如果你的应用支持多窗口的话,通常会把多个 window 对象存放在一个数组里面,但这次不是。 190 | convertWindow = null; 191 | }); 192 | convertWindow.webContents.on('did-finish-load', function () { 193 | }); 194 | }); 195 | 196 | function openCodeWindow() { 197 | if (codeWindow) { 198 | return; 199 | } 200 | codeWindow = new BrowserWindow({ 201 | icon: './src/images/icon.png', 202 | title: 'Convert image format', 203 | width: 480, 204 | height: 320, 205 | frame: true, 206 | resizable: true, 207 | webPreferences: { 208 | sandbox: false, 209 | enableRemoteModule: true, 210 | nodeIntegration: true, 211 | nodeIntegrationInWorker: true, 212 | contextIsolation: false 213 | } 214 | }); 215 | let locate = ""; 216 | if (app.getLocale() === "zh-CN") { 217 | locate = "-zh-CN"; 218 | } 219 | // 加载应用的 index.html 220 | codeWindow.loadURL(`http://localhost:${initialPort}` + `/code${locate}.html`); 221 | 222 | // 打开开发工具 223 | // codeWindow.openDevTools(); 224 | // 当 window 被关闭,这个事件会被发出 225 | codeWindow.on('closed', function () { 226 | // 取消引用 window 对象,如果你的应用支持多窗口的话,通常会把多个 window 对象存放在一个数组里面,但这次不是。 227 | codeWindow = null; 228 | }); 229 | codeWindow.webContents.on('did-finish-load', function () { 230 | }); 231 | } 232 | 233 | ipcMain.on('open-code-window', function () { 234 | openCodeWindow() 235 | }); 236 | ipcMain.on('open-video-window', function () { 237 | if (videoWindow) { 238 | return; 239 | } 240 | videoWindow = new BrowserWindow({ 241 | icon: './src/images/icon.png', 242 | title: 'Video', 243 | width: 480, 244 | height: 320, 245 | frame: true, 246 | resizable: true, 247 | webPreferences: { 248 | sandbox: false, 249 | enableRemoteModule: true, 250 | nodeIntegration: true, 251 | nodeIntegrationInWorker: true, 252 | contextIsolation: false 253 | } 254 | }); 255 | let locate = ""; 256 | if (app.getLocale() === "zh-CN") { 257 | locate = "-zh-CN"; 258 | } 259 | // 加载应用的 index.html 260 | videoWindow.loadURL(`http://localhost:${initialPort}` + `/video${locate}.html`); 261 | 262 | // 打开开发工具 263 | // videoWindow.openDevTools(); 264 | // 当 window 被关闭,这个事件会被发出 265 | videoWindow.on('closed', function () { 266 | // 取消引用 window 对象,如果你的应用支持多窗口的话,通常会把多个 window 对象存放在一个数组里面,但这次不是。 267 | videoWindow = null; 268 | }); 269 | videoWindow.webContents.on('did-finish-load', function () { 270 | }); 271 | }); 272 | ipcMain.on('open-audio-window', function () { 273 | if (audioWindow) { 274 | return; 275 | } 276 | audioWindow = new BrowserWindow({ 277 | icon: './src/images/icon.png', 278 | title: 'Video', 279 | width: 480, 280 | height: 720, 281 | frame: true, 282 | resizable: true, 283 | webPreferences: { 284 | sandbox: false, 285 | enableRemoteModule: true, 286 | nodeIntegration: true, 287 | nodeIntegrationInWorker: true, 288 | contextIsolation: false 289 | } 290 | }); 291 | let locate = ""; 292 | if (app.getLocale() === "zh-CN") { 293 | locate = "-zh-CN"; 294 | } 295 | // 加载应用的 index.html 296 | audioWindow.loadURL('file://' + __dirname + `/public/audio${locate}.html`); 297 | 298 | // 打开开发工具 299 | // audioWindow.openDevTools(); 300 | // 当 window 被关闭,这个事件会被发出 301 | audioWindow.on('closed', function () { 302 | // 取消引用 window 对象,如果你的应用支持多窗口的话,通常会把多个 window 对象存放在一个数组里面,但这次不是。 303 | audioWindow = null; 304 | }); 305 | audioWindow.webContents.on('did-finish-load', function () { 306 | }); 307 | }); 308 | 309 | ipcMain.on('open-settings-window', function () { 310 | console.log('app.getLocale()', app.getLocale()); 311 | if (settingsWindow) { 312 | return; 313 | } 314 | settingsWindow = new BrowserWindow({ 315 | width: 360, 316 | height: 440, 317 | icon: './src/images/icon.png', 318 | frame: true, 319 | title: 'Settings', 320 | resizable: false, 321 | 'auto-hide-menu-bar': true, 322 | webPreferences: { 323 | nodeIntegration: true, 324 | nodeIntegrationInWorker: true, 325 | contextIsolation: false 326 | } 327 | }); 328 | let locate = ""; 329 | if (app.getLocale() === "zh-CN") { 330 | locate = "-zh-CN"; 331 | } 332 | settingsWindow.loadURL(`http://localhost:${initialPort}` + `/settings${locate}.html`); 333 | // 打开开发工具 334 | //settingsWindow.openDevTools(); 335 | 336 | settingsWindow.on('closed', function () { 337 | settingsWindow = null; 338 | }); 339 | 340 | settingsWindow.webContents.on('did-finish-load', function () { 341 | // 342 | }); 343 | }); 344 | ipcMain.on('set-quality', function (event, arg1, arg2) { 345 | configuration.set(arg1, arg2); 346 | mainWindow.webContents.send('quality', configuration.get('jpg'), configuration.get('webp')); 347 | }); 348 | ipcMain.on('maxWidth', function (event, value) { 349 | configuration.set('maxWidth', value); 350 | mainWindow.webContents.send('maxWidth', value); 351 | }); 352 | ipcMain.on('maxHeightVideo', function (event, value) { 353 | configuration.set('maxHeightVideo', value); 354 | mainWindow.webContents.send('maxHeightVideo', value); 355 | }); 356 | ipcMain.on('maxHeight', function (event, value) { 357 | configuration.set('maxHeight', value); 358 | mainWindow.webContents.send('maxHeight', value); 359 | }); 360 | ipcMain.on('backup', function (event, value) { 361 | configuration.set('backup', value); 362 | mainWindow.webContents.send('backup', value); 363 | }); 364 | ipcMain.on('set-share', function (event, count, size) { 365 | configuration.set('count', count); 366 | configuration.set('size', size); 367 | mainWindow.webContents.send('share-data', count, size); 368 | }); 369 | ipcMain.handle('dialog:openMultiFileSelect', () => { 370 | return dialog.showOpenDialog({ 371 | properties: ['openFile', 'multiSelections'] 372 | }) 373 | .then((result) => { 374 | // Bail early if user cancelled dialog 375 | if (result.canceled) { 376 | return 377 | } 378 | return result.filePaths; 379 | }) 380 | }) 381 | ipcMain.on('app_version', (event) => { 382 | console.log(app.getVersion()) 383 | event.sender.send('app_version', {version: app.getVersion()}); 384 | }); 385 | 386 | autoUpdater.on('update-available', () => { 387 | mainWindow.webContents.send('update_available'); 388 | }); 389 | 390 | autoUpdater.on('update-downloaded', () => { 391 | mainWindow.webContents.send('update_downloaded'); 392 | }); 393 | 394 | ipcMain.on('restart_app', () => { 395 | autoUpdater.quitAndInstall(); 396 | }); 397 | const submenu = [ 398 | { 399 | label: 'More Settings', 400 | click: async () => { 401 | const p = path.join(getUserHome(), 'hummingbird-config.json'); 402 | shell.openPath(p); 403 | } 404 | }, 405 | {type: 'separator'}, 406 | { 407 | label: 'Compression Logs', 408 | click: async () => { 409 | const p = path.join(getUserHome(), 'hummingbird-log.txt'); 410 | shell.openPath(p); 411 | } 412 | }, 413 | { 414 | label: 'Application Logs', 415 | click: async () => { 416 | const logPath = log.transports.file.getFile().path; 417 | shell.showItemInFolder(logPath); 418 | } 419 | }, 420 | { 421 | label: 'Get File Encoding', 422 | click: async () => { 423 | openCodeWindow() 424 | } 425 | }, 426 | ] 427 | const template = [ 428 | // { role: 'appMenu' } 429 | ...(isMac 430 | ? [{ 431 | label: app.name, 432 | submenu: [ 433 | {role: 'about'}, 434 | {type: 'separator'}, 435 | ...submenu, 436 | {type: 'separator'}, 437 | {role: 'hide'}, 438 | {role: 'hideOthers'}, 439 | {role: 'unhide'}, 440 | {type: 'separator'}, 441 | {role: 'quit'} 442 | ] 443 | }] 444 | : [{ 445 | label: app.name, 446 | submenu: [...submenu] 447 | }]), 448 | // { role: 'editMenu' } 449 | { 450 | label: 'Edit', 451 | submenu: [ 452 | {role: 'undo'}, 453 | {role: 'redo'}, 454 | {type: 'separator'}, 455 | {role: 'cut'}, 456 | {role: 'copy'}, 457 | {role: 'paste'}, 458 | ...(isMac 459 | ? [ 460 | {role: 'pasteAndMatchStyle'}, 461 | {role: 'delete'}, 462 | {role: 'selectAll'}, 463 | {type: 'separator'}, 464 | { 465 | label: 'Speech', 466 | submenu: [ 467 | {role: 'startSpeaking'}, 468 | {role: 'stopSpeaking'} 469 | ] 470 | } 471 | ] 472 | : [ 473 | {role: 'delete'}, 474 | {type: 'separator'}, 475 | {role: 'selectAll'} 476 | ]) 477 | ] 478 | }, 479 | // { role: 'viewMenu' } 480 | { 481 | label: 'View', 482 | submenu: [ 483 | {role: 'reload'}, 484 | {type: 'separator'}, 485 | {role: 'togglefullscreen'} 486 | ] 487 | }, 488 | // { role: 'windowMenu' } 489 | { 490 | label: 'Window', 491 | submenu: [ 492 | {role: 'minimize'}, 493 | {role: 'zoom'}, 494 | ...(isMac 495 | ? [ 496 | {type: 'separator'}, 497 | {role: 'front'}, 498 | {type: 'separator'}, 499 | {role: 'window'} 500 | ] 501 | : [ 502 | {role: 'close'} 503 | ]) 504 | ] 505 | }, 506 | { 507 | role: 'help', 508 | submenu: [ 509 | { 510 | label: 'Report An Issue..', 511 | click: async () => { 512 | await shell.openExternal('https://github.com/leibnizli/hummingbird/issues') 513 | } 514 | }, 515 | { 516 | label: 'Website', 517 | click: async () => { 518 | await shell.openExternal('https://arayofsunshine.dev/hummingbird') 519 | } 520 | }, 521 | { 522 | label: 'Buy Me A Coffee', 523 | click: async () => { 524 | await shell.openExternal('https://buy.arayofsunshine.dev') 525 | } 526 | }, 527 | { 528 | label: 'Share To Twitter', 529 | click: async () => { 530 | shell.openExternal(`http://twitter.com/share?text=Hummingbird App has helped me process pictures ${configuration.get('count')} times and compressed the space ${(configuration.get('size') / (1024 * 1024)).toFixed(4)}M&url=https://github.com/leibnizli/hummingbird`); 531 | } 532 | }, 533 | ] 534 | } 535 | ] 536 | 537 | const menu = Menu.buildFromTemplate(template); 538 | Menu.setApplicationMenu(menu); 539 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18n'; 2 | import {getUserHome} from "./util.js"; 3 | import configuration from "../configuration"; 4 | const { webUtils } = require('electron') 5 | const ffmpegStatic = require('ffmpeg-static'); 6 | const ffprobeStatic = require('ffprobe-static'); 7 | const ffmpeg = require('fluent-ffmpeg'); 8 | const sharp = require('sharp'); 9 | const fs = require("fs"); 10 | const path = require('path'); 11 | const {ipcRenderer} = require('electron'); 12 | const log = require('electron-log'); 13 | const imagemin = require('imagemin'); 14 | const imageminPngquant = require('imagemin-pngquant'); 15 | const imageminOptipng = require('imagemin-optipng'); 16 | const imageminSvgo = require('imagemin-svgo'); 17 | const imageminGifsicle = require('imagemin-gifsicle'); 18 | const imageminWebp = require('imagemin-webp'); 19 | const gulp = require('gulp'); 20 | const htmlmin = require('gulp-htmlmin'); 21 | const uglify = require('gulp-uglify'); 22 | const rename = require("gulp-rename"); 23 | const cleanCSS = require('gulp-clean-css'); 24 | const mime = require('mime'); 25 | 26 | // Configure logging for renderer process - safely check if transports exist 27 | try { 28 | if (log.transports && log.transports.file) { 29 | log.transports.file.level = 'info'; 30 | } 31 | if (log.transports && log.transports.console) { 32 | log.transports.console.level = 'info'; 33 | } 34 | } catch (err) { 35 | // Ignore if transports are not available 36 | } 37 | 38 | // Override console in renderer process for production 39 | const originalConsoleLog = console.log; 40 | const originalConsoleError = console.error; 41 | const originalConsoleWarn = console.warn; 42 | 43 | console.log = function(...args) { 44 | try { 45 | log.info(...args); 46 | } catch (e) { 47 | // Fallback to original if log fails 48 | } 49 | originalConsoleLog.apply(console, args); 50 | }; 51 | 52 | console.error = function(...args) { 53 | try { 54 | log.error(...args); 55 | } catch (e) { 56 | // Fallback to original if log fails 57 | } 58 | originalConsoleError.apply(console, args); 59 | }; 60 | 61 | console.warn = function(...args) { 62 | try { 63 | log.warn(...args); 64 | } catch (e) { 65 | // Fallback to original if log fails 66 | } 67 | originalConsoleWarn.apply(console, args); 68 | }; 69 | 70 | // Tell fluent-ffmpeg where it can find FFmpeg and FFprobe 71 | console.log('Setting FFmpeg path to:', ffmpegStatic); 72 | console.log('Setting FFprobe path to:', ffprobeStatic.path); 73 | ffmpeg.setFfmpegPath(ffmpegStatic); 74 | ffmpeg.setFfprobePath(ffprobeStatic.path); 75 | 76 | // Verify FFmpeg and FFprobe are accessible 77 | try { 78 | const fs = require('fs'); 79 | if (fs.existsSync(ffmpegStatic)) { 80 | console.log('FFmpeg binary exists at path'); 81 | } else { 82 | console.error('FFmpeg binary NOT found at path:', ffmpegStatic); 83 | } 84 | if (fs.existsSync(ffprobeStatic.path)) { 85 | console.log('FFprobe binary exists at path'); 86 | } else { 87 | console.error('FFprobe binary NOT found at path:', ffprobeStatic.path); 88 | } 89 | } catch (err) { 90 | console.error('Error checking FFmpeg/FFprobe paths:', err); 91 | } 92 | 93 | let appPath = ""; 94 | const lang = navigator.language 95 | ipcRenderer.on('appPath', (event, p) => { 96 | appPath = p 97 | console.log(`App path: ${appPath}`) 98 | /* i18n config */ 99 | i18n.configure({ 100 | updateFiles: false, 101 | locales: ['en-US', 'zh-CN'], 102 | directory: path.join(appPath, 'locales'), 103 | defaultLocale: /zh/.test(lang) ? 'zh-CN' : 'en-US' 104 | }); 105 | new App("#ui-app"); 106 | }); 107 | console.log(__dirname + '/locales') 108 | const Pie = require("./components/pie"); 109 | let jpgValue, webpValue, backup,progressive,png, maxWidth = configuration.get('maxWidth') || 0, 110 | maxHeight = configuration.get('maxHeight') || 0; 111 | let maxHeightVideo = configuration.get('maxHeightVideo') || 0; 112 | 113 | ipcRenderer.on('quality', function (e, arg1, arg2) { 114 | jpgValue = arg1; 115 | webpValue = arg2; 116 | }); 117 | ipcRenderer.on('backup', function (e, arg1) { 118 | backup = arg1; 119 | }); 120 | ipcRenderer.on('progressive', function (e, arg1) { 121 | progressive = arg1; 122 | }); 123 | ipcRenderer.on('png', function (e, arg1) { 124 | png = arg1; 125 | }); 126 | 127 | function setTip() { 128 | const tip = document.getElementById("tip"); 129 | tip.innerHTML = ''; 130 | if (maxWidth > 0) { 131 | tip.innerHTML = `maxWidth:${maxWidth}`; 132 | } 133 | if (maxHeight > 0) { 134 | tip.innerHTML = `maxHeight:${maxHeight}`; 135 | } 136 | if (maxWidth > 0 && maxHeight > 0) { 137 | tip.innerHTML = `maxWidth:${maxWidth} maxHeight:${maxHeight}`; 138 | } 139 | } 140 | 141 | setTip() 142 | ipcRenderer.on('maxWidth', function (e, arg1) { 143 | maxWidth = arg1; 144 | setTip() 145 | }); 146 | ipcRenderer.on('maxHeight', function (e, arg1) { 147 | maxHeight = arg1; 148 | setTip() 149 | }); 150 | ipcRenderer.on('maxHeightVideo', function (e, arg1) { 151 | maxHeightVideo = arg1; 152 | }); 153 | 154 | function getFilesizeInBytes(filename) { 155 | var stats = fs.statSync(filename); 156 | return stats.size; 157 | } 158 | 159 | function App(el, options) { 160 | this.el = typeof el === 'string' ? document.querySelector(el) : el; 161 | this.options = options; 162 | this.status = "waiting"; 163 | this.filesArray = []; 164 | this.diff = 0; 165 | this.Timer = null; 166 | this.statusHtml = { 167 | waiting: '
', 168 | drop: '
\ 169 |
\ 170 |
\ 171 |
\ 172 |
\ 173 |

' 174 | } 175 | this._init(); 176 | } 177 | 178 | App.prototype = { 179 | _init: function () { 180 | this._updateState(); 181 | const waitingEl = this.el.querySelector(".ui-area-waiting"); 182 | if (waitingEl) { 183 | waitingEl.innerHTML = i18n.__('waiting'); 184 | } 185 | 186 | const importBtn = this.el.querySelector("#import"); 187 | if (importBtn) { 188 | importBtn.addEventListener("click", (e) => { 189 | e.preventDefault(); 190 | ipcRenderer.invoke('dialog:openMultiFileSelect').then((paths) => { 191 | if (paths === undefined) { 192 | return; 193 | } // Dialog was cancelled 194 | this.filesArray = []; 195 | this.diff = 0; 196 | this.status = "drop"; 197 | this._updateState(); 198 | for (let p of paths) { 199 | const mime_type = mime.getType(p); 200 | this.filesArray.push({ 201 | size: getFilesizeInBytes(p), 202 | name: path.basename(p), 203 | path: p, 204 | type: mime_type 205 | }); 206 | } 207 | this._delFiles(this.filesArray); 208 | }); 209 | }); 210 | } 211 | 212 | // 阻止默认的拖拽行为 213 | document.addEventListener('dragenter', (e) => { 214 | e.preventDefault(); 215 | const dropArea = e.target.closest('.ui-area-drop'); 216 | if (dropArea) { 217 | dropArea.classList.add("ui-area-drop-have"); 218 | const waitingEl = dropArea.querySelector(".ui-area-waiting"); 219 | if (waitingEl) { 220 | waitingEl.innerHTML = i18n.__('dragenter'); 221 | } 222 | } 223 | }); 224 | 225 | document.addEventListener('dragleave', (e) => { 226 | e.preventDefault(); 227 | const dropArea = e.target.closest('.ui-area-drop'); 228 | if (dropArea) { 229 | dropArea.classList.remove("ui-area-drop-have"); 230 | const waitingEl = dropArea.querySelector(".ui-area-waiting"); 231 | if (waitingEl) { 232 | waitingEl.innerHTML = i18n.__('waiting'); 233 | } 234 | } 235 | }); 236 | 237 | document.addEventListener('dragover', (e) => { 238 | e.preventDefault(); 239 | }); 240 | 241 | document.addEventListener('drop', (e) => { 242 | e.preventDefault(); 243 | const dropArea = e.target.closest('.ui-area-drop'); 244 | if (dropArea) { 245 | dropArea.classList.remove("ui-area-drop-have"); 246 | this.filesArray = []; 247 | this.diff = 0; 248 | 249 | // 处理拖放的文件和文件夹 250 | const items = e.dataTransfer.items; 251 | if (items) { 252 | this._filterFiles(items); 253 | } else { 254 | // 降级处理:如果不支持 items,则直接处理文件 255 | const files = e.dataTransfer.files; 256 | for (let i = 0; i < files.length; i++) { 257 | const file = files[i]; 258 | if (file.type.indexOf("image") > -1 || 259 | file.type.indexOf("css") > -1 || 260 | file.type.indexOf("javascript") > -1 || 261 | file.type.indexOf("html") > -1 || 262 | file.type.indexOf("mp4") > -1) { 263 | this.filesArray.push({ 264 | size: file.size, 265 | name: file.name, 266 | path: file.path, 267 | type: file.type 268 | }); 269 | } 270 | if (file.name.match(/\.(mov|MOV)$/)) { 271 | this.filesArray.push({ 272 | size: file.size, 273 | name: file.name, 274 | path: file.path, 275 | type: 'video/mov' 276 | }); 277 | } 278 | } 279 | if (this.filesArray.length > 0) { 280 | this.status = "drop"; 281 | this._updateState(); 282 | this._delFiles(this.filesArray); 283 | } 284 | } 285 | } 286 | }); 287 | }, 288 | _filterFiles: function (items) { 289 | if (items.length === 0) { 290 | const waitingEl = this.el.querySelector(".ui-area-waiting"); 291 | if (waitingEl) { 292 | waitingEl.innerHTML = i18n.__('waiting'); 293 | } 294 | return false; 295 | } 296 | 297 | if (!this.time) { 298 | this.time = Date.now(); 299 | } 300 | 301 | // 处理所有拖放的项目 302 | for (let i = 0; i < items.length; i++) { 303 | const entry = items[i].webkitGetAsEntry(); 304 | if (entry) { 305 | this._traverseFileTree(entry); 306 | } else if (items[i].kind === 'file') { 307 | // 降级处理:如果不支持 webkitGetAsEntry 308 | const file = items[i].getAsFile(); 309 | this._handleFile(file); 310 | } 311 | } 312 | }, 313 | _handleFile: function(file) { 314 | if (!file) return; 315 | 316 | // 检查文件类型 317 | if (file.type.indexOf("image") > -1 || 318 | file.type.indexOf("css") > -1 || 319 | file.type.indexOf("javascript") > -1 || 320 | file.type.indexOf("html") > -1 || 321 | file.type.indexOf("mp4") > -1) { 322 | this.filesArray.push({ 323 | size: file.size, 324 | name: file.name, 325 | path: webUtils.getPathForFile(file) || '', // 在普通 Chrome 中可能没有 path 326 | type: file.type 327 | }); 328 | } 329 | if (file.name.match(/\.(mov|MOV)$/)) { 330 | this.filesArray.push({ 331 | size: file.size, 332 | name: file.name, 333 | path: webUtils.getPathForFile(file) || '', // 在普通 Chrome 中可能没有 path 334 | type: 'video/mov' 335 | }); 336 | } 337 | }, 338 | _traverseFileTree: function (item) { 339 | const self = this; 340 | if (item.isFile) { 341 | item.file((file) => { 342 | clearTimeout(this.Timer); 343 | this.Timer = setTimeout(() => { 344 | if (this.filesArray.length > 0) { 345 | this.status = "drop"; 346 | this._updateState(); 347 | this._delFiles(this.filesArray); 348 | } else { 349 | const waitingEl = this.el.querySelector(".ui-area-waiting"); 350 | if (waitingEl) { 351 | waitingEl.innerHTML = i18n.__('waiting'); 352 | } 353 | } 354 | }, 100); 355 | 356 | this._handleFile(file); 357 | }); 358 | } else if (item.isDirectory) { 359 | const dirReader = item.createReader(); 360 | const readEntries = function () { 361 | dirReader.readEntries(function (entries) { 362 | if (entries.length) { 363 | for (let i = 0; i < entries.length; i++) { 364 | self._traverseFileTree(entries[i]); 365 | } 366 | readEntries(); // 继续读取,直到所有条目都被处理 367 | } 368 | }); 369 | }; 370 | readEntries(); 371 | } 372 | }, 373 | _sharp: function (filePath) { 374 | return new Promise((resolve, reject) => { 375 | if (maxWidth > 0 && maxHeight < 1) { 376 | // Read input image metadata 377 | sharp(filePath) 378 | .metadata() 379 | .then(metadata => { 380 | // If image width exceeds target width, resize it proportionally 381 | if (metadata.width > maxWidth) { 382 | return sharp(filePath) 383 | .resize({width: Number(maxWidth)}) 384 | .toBuffer(function (err, buffer) { 385 | fs.writeFile(filePath, buffer, function (e) { 386 | console.log('Image has been resized to width ' + maxWidth + ' pixels'); 387 | resolve() 388 | }); 389 | }) 390 | } else { 391 | console.log('Image width does not exceed ' + maxWidth + ' pixels, no resizing needed'); 392 | resolve() 393 | } 394 | }) 395 | .catch(err => { 396 | console.error('Failed to read image metadata:', err); 397 | reject() 398 | }); 399 | } else if (maxWidth < 1 && maxHeight > 0) { 400 | // Read input image metadata 401 | sharp(filePath) 402 | .metadata() 403 | .then(metadata => { 404 | // If image width exceeds target width, resize it proportionally 405 | if (metadata.height > maxHeight) { 406 | return sharp(filePath) 407 | .resize({height: Number(maxHeight)}) 408 | .toBuffer(function (err, buffer) { 409 | fs.writeFile(filePath, buffer, function (e) { 410 | console.log('Image has been resized to height ' + maxHeight + ' pixels'); 411 | resolve() 412 | }); 413 | }) 414 | } else { 415 | console.log('Image width does not exceed ' + maxHeight + ' pixels, no resizing needed'); 416 | resolve() 417 | } 418 | }) 419 | .catch(err => { 420 | console.error('Failed to read image metadata:', err); 421 | reject() 422 | }); 423 | } else if (maxWidth > 0 && maxHeight > 0) { 424 | sharp(filePath) 425 | .metadata() 426 | .then(metadata => { 427 | // If image width exceeds target width, resize it proportionally 428 | if (metadata.width > maxWidth) { 429 | return sharp(filePath) 430 | .resize({width: Number(maxWidth)}) 431 | .toBuffer(function (err, buffer) { 432 | fs.writeFile(filePath, buffer, function (e) { 433 | console.log('Image has been resized to width ' + maxWidth + ' pixels'); 434 | resolve() 435 | }); 436 | }) 437 | } else { 438 | console.log('Image width does not exceed ' + maxHeight + ' pixels, no resizing needed'); 439 | resolve() 440 | } 441 | }) 442 | .catch(err => { 443 | console.error('Failed to read image metadata:', err); 444 | reject() 445 | }); 446 | } else { 447 | resolve() 448 | } 449 | }); 450 | }, 451 | _delFiles: function () { 452 | let p = 0; 453 | let pie = new Pie(), 454 | index = 0, 455 | self = this, 456 | len = this.filesArray.length; 457 | 458 | // Ensure DOM is ready before setting initial progress 459 | // Use requestAnimationFrame to ensure DOM is painted 460 | requestAnimationFrame(() => { 461 | requestAnimationFrame(() => { 462 | pie.set(0); 463 | }); 464 | }); 465 | 466 | const obj = new Proxy({count: 0}, { 467 | get: function (target, propKey, receiver) { 468 | return Reflect.get(target, propKey, receiver); 469 | }, 470 | set: function (target, propKey, value, receiver) { 471 | Reflect.set(target, propKey, value, receiver); 472 | if (target.count < 5) { 473 | filesHandle(index) 474 | } 475 | } 476 | }); 477 | 478 | function filesHandle(i) { 479 | if (i + 1 > len) return; 480 | index++; 481 | const filePath = self.filesArray[i].path; 482 | const fileDirname = path.dirname(filePath); 483 | const fileBasename = path.basename(filePath); 484 | const extension = path.extname(filePath); 485 | const fileSourcePath = path.join(fileDirname, 'source', fileBasename); 486 | 487 | const name = path.basename(filePath, `${extension}`); 488 | const targetPath = path.join(fileDirname, `${name}.min${extension}`); 489 | const options = []; 490 | //writeFile 491 | if (backup) { 492 | //mkdir 493 | self._mkdirSync(path.join(fileDirname, 'source')); 494 | if (self.filesArray[i].type.indexOf("image") > -1) { 495 | !fs.existsSync(fileSourcePath) && fs.writeFileSync(fileSourcePath, fs.readFileSync(filePath)); 496 | } else { 497 | fs.writeFileSync(fileSourcePath, fs.readFileSync(filePath)); 498 | } 499 | } 500 | switch (self.filesArray[i].type) { 501 | case "image/svg+xml": 502 | imagemin([filePath], fileDirname, { 503 | plugins: [ 504 | imageminSvgo({}) 505 | ] 506 | }).then(files => { 507 | runSucceed(i, [ 508 | {size: getFilesizeInBytes(filePath)} 509 | ]); 510 | }, err => { 511 | runSkip(i, err) 512 | }); 513 | break; 514 | case "image/jpeg": 515 | self._sharp(filePath).finally(() => { 516 | sharp(filePath).jpeg({ 517 | mozjpeg: true, 518 | progressive, 519 | quality: jpgValue || 80 520 | }) 521 | .toBuffer(function (err, buffer) { 522 | if (err) { 523 | runSkip(i, err) 524 | } 525 | fs.writeFile(filePath, buffer, function (e) { 526 | runSucceed(i, [{data: buffer}], "img"); 527 | }); 528 | }); 529 | }); 530 | break; 531 | case "image/png": 532 | self._sharp(filePath).finally( 533 | () => { 534 | imagemin([filePath], fileDirname, { 535 | plugins: [ 536 | imageminOptipng({optimizationLevel: 2}),//OptiPNG 无损压缩算法 537 | imageminPngquant({ 538 | quality: png, 539 | })//Pngquant 有损压缩算法 540 | ] 541 | }).then(files => { 542 | runSucceed(i, files, "img"); 543 | }, err => { 544 | runSkip(i, err) 545 | }); 546 | } 547 | ); 548 | break; 549 | case "image/gif": 550 | self._sharp(filePath).finally( 551 | () => { 552 | imagemin([filePath], fileDirname, { 553 | plugins: [imageminGifsicle()] 554 | }).then(files => { 555 | runSucceed(i, files, "img"); 556 | }, err => { 557 | runSkip(i, err) 558 | }); 559 | } 560 | ) 561 | 562 | break; 563 | case "image/webp": 564 | self._sharp(filePath).finally( 565 | () => { 566 | imagemin([filePath], fileDirname, { 567 | plugins: [ 568 | imageminWebp({quality: webpValue || 85}) 569 | ] 570 | }).then(files => { 571 | runSucceed(i, files, "img"); 572 | }, err => { 573 | runSkip(i, err) 574 | }); 575 | } 576 | ) 577 | 578 | break; 579 | case "text/css": 580 | gulp.src(filePath).pipe(cleanCSS({compatibility: 'ie8'})).pipe(rename({suffix: '.min'})).pipe(gulp.dest(fileDirname)).on('end', function () { 581 | runSucceed(i, [ 582 | { 583 | size: getFilesizeInBytes( 584 | `${path.dirname(filePath)}/${path.parse(filePath).name}.min${path.parse(filePath).ext}` 585 | ) 586 | } 587 | ]); 588 | }); 589 | break; 590 | case "text/javascript": 591 | gulp.src(filePath).pipe(uglify()).pipe(rename({suffix: '.min'})).pipe(gulp.dest(fileDirname)).on('end', function () { 592 | runSucceed(i, [ 593 | { 594 | size: getFilesizeInBytes( 595 | `${path.dirname(filePath)}/${path.parse(filePath).name}.min${path.parse(filePath).ext}` 596 | ) 597 | } 598 | ]); 599 | }); 600 | break; 601 | case "text/html": 602 | gulp.src(filePath).pipe(htmlmin({collapseWhitespace: true})).pipe(gulp.dest(fileDirname)).on('end', function () { 603 | runSucceed(i, [ 604 | {size: getFilesizeInBytes(filePath)} 605 | ]); 606 | }); 607 | break; 608 | case "video/mp4": { 609 | console.log('Starting MP4 processing...'); 610 | options.push('-crf 28'); 611 | if (maxHeightVideo > 0) { 612 | options.push(`-vf scale=-2:${maxHeightVideo}`); 613 | } 614 | 615 | // Get video duration first, then start processing 616 | console.log('Calling ffprobe for:', filePath); 617 | ffmpeg.ffprobe(filePath, (err, metadata) => { 618 | let videoDuration = 0; 619 | if (err) { 620 | console.error('ffprobe error:', err); 621 | } else { 622 | console.log('ffprobe success, metadata format:', metadata ? metadata.format : 'no format'); 623 | } 624 | if (!err && metadata && metadata.format && metadata.format.duration) { 625 | videoDuration = metadata.format.duration; 626 | console.log('Video duration:', videoDuration, 'seconds'); 627 | } else { 628 | console.warn('Could not get video duration, progress will not be shown'); 629 | } 630 | 631 | const mp4Command = ffmpeg() 632 | .input(filePath) 633 | .fps(30) 634 | .outputOptions(options) 635 | .on('progress', (progress) => { 636 | try { 637 | if (progress && progress.timemark) { 638 | if (videoDuration > 0) { 639 | // Parse timemark (format: "00:00:05.73") 640 | const timemarkParts = progress.timemark.split(':'); 641 | const hours = parseInt(timemarkParts[0]) || 0; 642 | const minutes = parseInt(timemarkParts[1]) || 0; 643 | const seconds = parseFloat(timemarkParts[2]) || 0; 644 | let currentTime = hours * 3600 + minutes * 60 + seconds; 645 | if (currentTime < 0) { 646 | currentTime = 0; 647 | } 648 | const videoPercent = Math.min(100, (currentTime / videoDuration) * 100); 649 | const currentProgress = ((p / len) * 100 + (1 / len) * videoPercent).toFixed(0); 650 | console.log('Video progress:', currentProgress + '%'); 651 | 652 | if (pie && typeof pie.set === 'function') { 653 | pie.set(currentProgress); 654 | } 655 | } 656 | } 657 | } catch (err) { 658 | console.error('Error updating video progress:', err); 659 | } 660 | }) 661 | .on('end', () => { 662 | console.log('FFmpeg has finished.'); 663 | runSucceed(i, [ 664 | {size: getFilesizeInBytes(targetPath)} 665 | ]); 666 | }) 667 | .on('error', (error) => { 668 | console.error('FFmpeg error:', error); 669 | runSkip(i, error); 670 | }); 671 | 672 | mp4Command.saveToFile(targetPath); 673 | }); 674 | break; 675 | } 676 | case "video/mov": { 677 | console.log('Starting MOV processing...'); 678 | options.push('-crf 28'); 679 | options.push('-c:v libx264'); 680 | //-c:a copy 选项用于直接复制音频流,而不对音频进行重新编码 681 | options.push('-c:a copy'); 682 | if (maxHeightVideo > 0) { 683 | options.push(`-vf scale=-2:${maxHeightVideo}`); 684 | } 685 | 686 | // Get video duration first, then start processing 687 | console.log('Calling ffprobe for:', filePath); 688 | ffmpeg.ffprobe(filePath, (err, metadata) => { 689 | let videoDuration = 0; 690 | if (err) { 691 | console.error('ffprobe error:', err); 692 | } else { 693 | console.log('ffprobe success, metadata format:', metadata ? metadata.format : 'no format'); 694 | } 695 | if (!err && metadata && metadata.format && metadata.format.duration) { 696 | videoDuration = metadata.format.duration; 697 | console.log('Video duration:', videoDuration, 'seconds'); 698 | } else { 699 | console.warn('Could not get video duration, progress will not be shown'); 700 | } 701 | 702 | const movCommand = ffmpeg() 703 | .input(filePath) 704 | .fps(30) 705 | .outputOptions(options) 706 | .on('progress', (progress) => { 707 | try { 708 | if (progress && progress.timemark) { 709 | if (videoDuration > 0) { 710 | // Parse timemark (format: "00:00:05.73") 711 | const timemarkParts = progress.timemark.split(':'); 712 | const hours = parseInt(timemarkParts[0]) || 0; 713 | const minutes = parseInt(timemarkParts[1]) || 0; 714 | const seconds = parseFloat(timemarkParts[2]) || 0; 715 | const currentTime = hours * 3600 + minutes * 60 + seconds; 716 | 717 | const videoPercent = Math.min(100, (currentTime / videoDuration) * 100); 718 | const currentProgress = ((p / len) * 100 + (1 / len) * videoPercent).toFixed(0); 719 | console.log('Video progress:', currentProgress + '%'); 720 | 721 | if (pie && typeof pie.set === 'function') { 722 | pie.set(currentProgress); 723 | } 724 | } 725 | } 726 | } catch (err) { 727 | console.error('Error updating video progress:', err); 728 | } 729 | }) 730 | .on('end', () => { 731 | console.log('FFmpeg has finished.'); 732 | runSucceed(i, [ 733 | {size: getFilesizeInBytes(targetPath)} 734 | ]); 735 | }) 736 | .on('error', (error) => { 737 | console.error('FFmpeg error:', error); 738 | runSkip(i, error); 739 | }); 740 | 741 | movCommand.saveToFile(targetPath); 742 | }); 743 | break; 744 | } 745 | default: 746 | runSkip(i); 747 | } 748 | obj.count++; 749 | } 750 | 751 | function runSucceed(i, files, type) { 752 | self.filesArray[i].time = new Date().toString(); 753 | if (type === "img") { 754 | if (files) { 755 | self.filesArray[i].optimized = files[0] ? files[0].data.length : self.filesArray[i].size; 756 | } 757 | } else { 758 | self.filesArray[i].optimized = files[0] ? files[0].size : self.filesArray[i].size; 759 | } 760 | p++; 761 | pie.set(((p / len) * 100).toFixed(0)); 762 | obj.count--; 763 | if (p >= len) { 764 | self._finished(len); 765 | } 766 | } 767 | 768 | function runSkip(i, err) { 769 | self.filesArray[i].time = new Date().toString(); 770 | console.log(i, err) 771 | self.filesArray[i].skip = true; 772 | self.filesArray[i].optimized = self.filesArray[i].size; 773 | p++; 774 | pie.set(((p / len) * 100).toFixed(0)); 775 | obj.count--; 776 | if (p >= len) { 777 | self._finished(len); 778 | } 779 | } 780 | 781 | if (len > 0) { 782 | filesHandle(0) 783 | } 784 | }, 785 | _finished: function (num) { 786 | this.status = "waiting"; 787 | this._updateState(); 788 | console.log(this.filesArray) 789 | let log = ""; 790 | this.filesArray.forEach(function (file) { 791 | this.diff += file.size - file.optimized; 792 | const fileDiff = file.size - file.optimized; 793 | log += `${file.time} ${file.name} ${file.size}B - ${file.optimized}B = ${fileDiff}B ${this.skip ? "skip" : ""} \n` 794 | }.bind(this)); 795 | this._updateProgress(num); 796 | ipcRenderer.send('set-share', window.shareCount + 1, window.shareSize + this.diff); 797 | 798 | const maxSizeInBytes = 1024 * 1024; // 1MB 799 | const logPath = path.join(getUserHome(), 'hummingbird-log.txt'); 800 | fs.stat(logPath, (err, stats) => { 801 | if (err) { 802 | console.error("Error occurred while getting file stats:", err); 803 | return; 804 | } 805 | if (stats.size > maxSizeInBytes) { 806 | // File size exceeds 1MB, clear its content 807 | fs.truncate(logPath, 0, (truncateErr) => { 808 | if (truncateErr) { 809 | console.error("Error occurred while truncating file:", truncateErr); 810 | return; 811 | } 812 | console.log("File content cleared successfully."); 813 | }); 814 | } 815 | fs.appendFile(logPath, log, err => { 816 | if (err) { 817 | console.error(err); 818 | } else { 819 | // done! 820 | } 821 | }); 822 | }); 823 | }, 824 | _mkdirSync: function (path) { 825 | try { 826 | fs.mkdirSync(path) 827 | } catch (e) { 828 | if (e.code !== 'EEXIST') throw e; 829 | } 830 | }, 831 | _updateState: function () { 832 | if (this.el) { 833 | this.el.querySelector('.ui-area-main').innerHTML = this.statusHtml[this.status]; 834 | } 835 | }, 836 | _updateProgress: function(num) { 837 | const waitingEl = this.el.querySelector(".ui-area-waiting"); 838 | if (waitingEl) { 839 | waitingEl.innerHTML = `${num} ${i18n.__('after')} ${(this.diff / (1024)).toFixed(3)}KB`; 840 | } 841 | } 842 | } 843 | module.exports = App; 844 | --------------------------------------------------------------------------------