├── 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 |
复制 Base64 字符串
20 |
21 |
22 |
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 |
Copy Base64 string
20 |
21 |
22 |
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 |
40 |
导出
41 |
42 |
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 |
Extract audio
20 |
Delete audio
21 |
22 |
Select the export format:
23 |
39 |
40 |
Export
41 |
42 |
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 |
22 | 循环播放所选区域
23 |
24 |
25 | 缩放:
26 |
27 |
28 |
29 |
在音频波浪上拖动鼠标即可选择需要保留的音频区间,可以同时添加多个选区,选区的范围可通过拖动左右边栏改变大小,点击选区选择当前选区,按下Delete 键可删除当前选区,按下Space 键可以播放或暂停音频。
30 |
31 |
44 |
导出
45 |
46 |
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 |
22 | Loop selected area
23 |
24 |
25 | Zoom:
26 |
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 |
43 |
44 |
Export
45 |
46 |
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 |
59 |
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 |
56 |
57 |
Export
58 |
59 |
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 |
72 |
视频:
73 |
87 |
88 |
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 |
72 |
Video:
73 |
84 |
85 |
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 | #
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 | 41kB
56 | 12kB
57 |
58 |
59 |
60 |
61 | #### png压缩对比
62 |
63 | 对于png24通道透明有比较好的压缩效果
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | 前
73 | 后
74 |
75 |
76 | 28.9kB
77 | 9.42kB
78 |
79 |
80 |
81 |
82 | #### svg压缩对比
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | 前
92 | 后
93 |
94 |
95 | 5.47kB
96 | 3.55kB
97 |
98 |
99 |
100 |
101 | ### 裁切音频
102 |
103 |
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 |
74 |
75 |
76 |
77 | Close
78 |
79 |
80 | Restart
81 |
82 |
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 |
75 |
76 |
77 |
78 | Close
79 |
80 |
81 | Restart
82 |
83 |
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 | #
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 | Before
55 | After
56 |
57 |
58 | 41kB
59 | 12kB
60 |
61 |
62 |
63 |
64 | #### png
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Before
74 | After
75 |
76 |
77 | 28.9kB
78 | 9.42kB
79 |
80 |
81 |
82 |
83 | #### svg
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | Before
93 | After
94 |
95 |
96 | 5.47kB
97 | 3.55kB
98 |
99 |
100 |
101 |
102 | #### mov
103 |
104 |
105 |
106 |
107 | Before
108 | After
109 |
110 |
111 | 1382.44MB
112 | 37.95MB
113 |
114 |
115 |
116 |
117 | ### Crop audio
118 |
119 |
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: '\
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 |
--------------------------------------------------------------------------------