mirror of
https://github.com/axllent/goptimize.git
synced 2025-07-05 16:38:25 -04:00
Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e795f587ef | ||
|
d94f93af58 | ||
|
065ff933c0 | ||
|
42a5284e57 | ||
|
3fde14166e | ||
|
0eb5cab8f8 | ||
|
a48750e9a1 | ||
|
e21049c75d | ||
|
b6ff2a7a5c | ||
|
c6ccb45a25 | ||
|
99756efb02 | ||
|
118c5bc9ac | ||
|
4d08aff7ec | ||
|
788c6ecf23 | ||
|
dedca9b41d | ||
|
74cb212d22 | ||
|
1a6942417b | ||
|
2444204b32 | ||
|
423ebc1494 | ||
|
e5ecbe9bdf | ||
|
cc2b9d6b88 | ||
|
db2f3b8534 | ||
|
b9e5afefd4 | ||
|
e8f5085a11 | ||
|
6956f5fb08 | ||
|
e55c5de522 | ||
|
75ca6b7017 | ||
|
ea2d490686 | ||
|
e8c7298c9c | ||
|
bb18d90d0e | ||
|
90240fef3b | ||
|
2b1b34a4e0 | ||
|
529ea6971c | ||
|
4fa6fed8c9 | ||
|
745bed1273 | ||
|
d3cd263300 | ||
|
88af2a0a0c | ||
|
fbb22582d1 | ||
|
adac20101d | ||
|
521f3f24c1 | ||
|
3f9f09880c | ||
|
eadd535e4c |
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "quarterly"
|
35
.github/workflows/build-release.yml
vendored
Normal file
35
.github/workflows/build-release.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Release Go Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
releases-matrix:
|
||||
name: Release Go Binary
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, darwin]
|
||||
goarch: ["386", amd64, arm, arm64]
|
||||
exclude:
|
||||
- goarch: "386"
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
goos: darwin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: wangyoucao577/go-release-action@v1
|
||||
with:
|
||||
binary_name: "goptimize"
|
||||
asset_name: "goptimize-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
extra_files: LICENSE README.md
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
md5sum: false
|
||||
overwrite: true
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
pre_command: export GO386=softfloat CGO_ENABLED=0
|
||||
ldflags: -s -w -X "main.version=${{ github.ref_name }}"
|
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "develop" ]
|
||||
schedule:
|
||||
- cron: '34 23 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
20
.github/workflows/tests.yml
vendored
Normal file
20
.github/workflows/tests.yml
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop, "feature/**"]
|
||||
push:
|
||||
branches: [develop, "feature/**"]
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.1
|
31
CHANGELOG.md
31
CHANGELOG.md
@ -1,21 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [dev]
|
||||
## [1.0.0]
|
||||
|
||||
- Use ghru/v2 for latest version checks and self-update functionality
|
||||
- Refactor code for consistency and error handling improvements
|
||||
- Update Go dependencies
|
||||
- Use GitHub Actions to build release binaries
|
||||
- Add quarterly dependabot module update
|
||||
- Add code quality checks & linting (golangci-lint)
|
||||
|
||||
## [0.2.3]
|
||||
|
||||
- Switch to kovidgoyal/imaging to address CVE-2023-36308
|
||||
|
||||
## [0.2.2]
|
||||
|
||||
- Optionally preserve exif data for supported formats
|
||||
- Update Go modules
|
||||
|
||||
## [0.2.1]
|
||||
|
||||
- Update core modules
|
||||
|
||||
## [0.2.0]
|
||||
|
||||
- Add threaded option (`-t`) to use all CPU cores
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
- Switch to go mods - go (>= 1.11 required)
|
||||
- Switch to axllent/semver for app updating
|
||||
|
||||
|
||||
## [0.0.3]
|
||||
|
||||
- Detect & skip animated GIFs
|
||||
|
||||
|
||||
## [0.0.2]
|
||||
|
||||
- Switch to [pflag](https://github.com/spf13/pflag) for better flag management
|
||||
|
||||
|
||||
## [0.0.1]
|
||||
|
||||
- Initial release
|
||||
|
31
LICENSE
31
LICENSE
@ -1,16 +1,21 @@
|
||||
Copyright 2019 Ralph Slooten
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2019-Now() Ralph Slooten
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
23
Makefile
23
Makefile
@ -1,23 +0,0 @@
|
||||
TAG=`git describe --tags`
|
||||
VERSION ?= `git describe --tags`
|
||||
LDFLAGS=-ldflags "-s -w -X main.version=${VERSION}"
|
||||
|
||||
build = echo "\n\nBuilding $(1)-$(2)" && GOOS=$(1) GOARCH=$(2) go build ${LDFLAGS} -o dist/goptimize_${VERSION}_$(1)_$(2) \
|
||||
&& bzip2 dist/goptimize_${VERSION}_$(1)_$(2)
|
||||
|
||||
goptimize: *.go go.*
|
||||
go build ${LDFLAGS} -o goptimize
|
||||
rm -rf /tmp/go-*
|
||||
|
||||
clean:
|
||||
rm -f goptimize
|
||||
|
||||
release:
|
||||
mkdir -p dist
|
||||
rm -f dist/goptimize_${VERSION}_*
|
||||
$(call build,linux,amd64)
|
||||
$(call build,linux,386)
|
||||
$(call build,linux,arm)
|
||||
$(call build,linux,arm64)
|
||||
$(call build,darwin,amd64)
|
||||
$(call build,darwin,386)
|
29
README.md
29
README.md
@ -1,8 +1,8 @@
|
||||
# Goptimizer - downscales and optimizes images
|
||||
# Goptimize - downscales and optimizes images
|
||||
|
||||
[](https://goreportcard.com/report/github.com/axllent/goptimize)
|
||||
|
||||
Goptimizer is a commandline utility written in Golang. It downscales and optimizes JPEG, PNG, GIF, TIFF and BMP files.
|
||||
Goptimize is a command-line utility written in Go. It downscales and optimizes JPEG, PNG, GIF, TIFF and BMP files.
|
||||
|
||||
Image downscaling/rotation is done within goptimize (`-m <width>x<height>`, see [Usage](#usage-options)), however optimization is done using the following additional tools (if they are installed):
|
||||
|
||||
@ -11,19 +11,17 @@ Image downscaling/rotation is done within goptimize (`-m <width>x<height>`, see
|
||||
- pngquant
|
||||
- gifsicle
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
Both `jpegoptim` & `jpegtran` have almost identical optimization, so if both are installed then just `jpegtran` is used for JPG optimization. PNG optimization however will run through both `optipng` & `pngquant` (if installed) as this can result in better optimization.
|
||||
|
||||
It is highly recommended to install the necessary optimization tools, however they are not required to run goptimize.
|
||||
|
||||
Goptimize will remove all exif data from JPEG files, auto-rotating those that depend on it for orientation.
|
||||
GGoptimize will by default remove all EXIF data from JPEG files and automatically rotate those that rely on it for orientation. This can be disabled by adding the `-e` flag.
|
||||
|
||||
It will also preserve (by default) the file's original modification times (`-p=false` to disable).
|
||||
|
||||
Animated GIF files are not supported and automatically get skipped.
|
||||
|
||||
Animated GIF files are not supported and are ignored.
|
||||
|
||||
## Usage options
|
||||
|
||||
@ -35,6 +33,7 @@ Options:
|
||||
-m, --max string downscale to a maximum width & height in pixels (<width>x<height>)
|
||||
-o, --out string output directory (default overwrites original)
|
||||
-p, --preserve preserve file modification times (default true)
|
||||
-t, --threaded run multi-threaded (use all CPU cores)
|
||||
-u, --update update to latest release
|
||||
-v, --version show version number
|
||||
-h, --help show help
|
||||
@ -45,7 +44,6 @@ Options:
|
||||
--optipng string optipng binary (default "optipng")
|
||||
```
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
- `./goptimize image.png` - optimize a PNG file
|
||||
@ -53,23 +51,10 @@ Options:
|
||||
- `./goptimize -m 1200x0 image.jpg` - optimize and downscale a JPG file to a maximum size of width of 1200px
|
||||
- `./goptimize -o out/ image.jpg` - optimize a JPG file and save it to `out/`
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
Download the appropriate binary from the [releases](https://github.com/axllent/goptimize/releases/latest), or if you have golang installed
|
||||
|
||||
|
||||
### Build requirements
|
||||
|
||||
Go >= 1.11 required.
|
||||
Download the appropriate binary from the [releases](https://github.com/axllent/goptimize/releases/latest), or if you have golang installed you can install from source:
|
||||
|
||||
```
|
||||
go get github.com/axllent/goptimize
|
||||
go install github.com/axllent/goptimize@latest
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
Some ideas for the future:
|
||||
|
||||
- Dry run
|
||||
- Option to copy exif data (how?)
|
||||
|
171
exifcopy.go
Normal file
171
exifcopy.go
Normal file
@ -0,0 +1,171 @@
|
||||
// https://stackoverflow.com/a/76779756
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
soi = 0xD8
|
||||
eoi = 0xD9
|
||||
sos = 0xDA
|
||||
exif = 0xE1
|
||||
copyright = 0xEE
|
||||
comment = 0xFE
|
||||
)
|
||||
|
||||
func isMetaTagType(tagType byte) bool {
|
||||
// Adapt as needed
|
||||
return tagType == exif || tagType == copyright || tagType == comment
|
||||
}
|
||||
|
||||
func copySegments(dst *bufio.Writer, src *bufio.Reader, filterSegment func(tagType byte) bool) error {
|
||||
var buf [2]byte
|
||||
_, err := io.ReadFull(src, buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if buf != [2]byte{0xFF, soi} {
|
||||
return errors.New("expected SOI")
|
||||
}
|
||||
for {
|
||||
_, err := io.ReadFull(src, buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if buf[0] != 0xFF {
|
||||
return errors.New("invalid tag type")
|
||||
}
|
||||
if buf[1] == eoi {
|
||||
// Hacky way to check for EOF
|
||||
_, err := src.Read(buf[:1])
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
// don't return an error as some cameras add the exif data at the end.
|
||||
// if n > 0 {
|
||||
// return errors.New("EOF expected after EOI")
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
sos := buf[1] == 0xDA
|
||||
filter := filterSegment(buf[1])
|
||||
if filter {
|
||||
_, err = dst.Write(buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = io.ReadFull(src, buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter {
|
||||
_, err = dst.Write(buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Includes the length, but not the tag, so subtract 2
|
||||
tagLength := ((uint16(buf[0]) << 8) | uint16(buf[1])) - 2
|
||||
if filter {
|
||||
_, err = io.CopyN(dst, src, int64(tagLength))
|
||||
} else {
|
||||
_, err = src.Discard(int(tagLength))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sos {
|
||||
// Find next tag `FF xx` in the stream where `xx != 0` to skip ECS
|
||||
// See https://stackoverflow.com/questions/2467137/parsing-jpeg-file-format-format-of-entropy-coded-segments-ecs
|
||||
for {
|
||||
bytes, err := src.Peek(2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes[0] == 0xFF {
|
||||
data, rstMrk := bytes[1] == 0, bytes[1] >= 0xD0 && bytes[1] <= 0xD7
|
||||
if !data && !rstMrk {
|
||||
break
|
||||
}
|
||||
}
|
||||
if filter {
|
||||
err = dst.WriteByte(bytes[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = src.Discard(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyMetadata(outImagePath, imagePath, metadataImagePath string) error {
|
||||
outFile, err := os.Create(outImagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = outFile.Close() }()
|
||||
writer := bufio.NewWriter(outFile)
|
||||
|
||||
imageFile, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = imageFile.Close() }()
|
||||
imageReader := bufio.NewReader(imageFile)
|
||||
|
||||
metaFile, err := os.Open(metadataImagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = metaFile.Close() }()
|
||||
metaReader := bufio.NewReader(metaFile)
|
||||
|
||||
_, err = writer.Write([]byte{0xFF, soi})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy metadata segments
|
||||
// It seems that they need to come first!
|
||||
err = copySegments(writer, metaReader, isMetaTagType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Copy all non-metadata segments
|
||||
err = copySegments(writer, imageReader, func(tagType byte) bool {
|
||||
return !isMetaTagType(tagType)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = writer.Write([]byte{0xFF, eoi})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Flush the writer, otherwise the last couple buffered writes (including the EOI) won't get written!
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func exifCopy(fromPath, toPath string) error {
|
||||
copyPath := toPath + "~"
|
||||
err := os.Rename(toPath, copyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.Remove(copyPath) }()
|
||||
return copyMetadata(toPath, copyPath, fromPath)
|
||||
}
|
14
go.mod
14
go.mod
@ -1,10 +1,14 @@
|
||||
module github.com/axllent/goptimize
|
||||
|
||||
go 1.13
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.1
|
||||
|
||||
require (
|
||||
github.com/axllent/ghru v1.1.3
|
||||
github.com/disintegration/imaging v1.6.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
||||
github.com/axllent/ghru/v2 v2.0.1
|
||||
github.com/kovidgoyal/imaging v1.6.4
|
||||
github.com/spf13/pflag v1.0.6
|
||||
golang.org/x/image v0.28.0
|
||||
)
|
||||
|
||||
require golang.org/x/mod v0.25.0 // indirect
|
||||
|
22
go.sum
22
go.sum
@ -1,12 +1,10 @@
|
||||
github.com/axllent/ghru v1.1.3 h1:n0jYsuqCYaHHAR6DraXZl8hpBY4j0XV47y5Lyym/jGo=
|
||||
github.com/axllent/ghru v1.1.3/go.mod h1:rFvMhcO1UAv2Cv6bXscS8EOc7qqNpfe8ZLp23utzs88=
|
||||
github.com/axllent/semver v0.0.0-20191103011746-394cefa91ee9 h1:LHNcCfePzgC/agAJs5a/5K3hFo8uW04bEdDKDkoX4do=
|
||||
github.com/axllent/semver v0.0.0-20191103011746-394cefa91ee9/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/disintegration/imaging v1.6.1 h1:JnBbK6ECIZb1NsWIikP9pd8gIlTIRx7fuDNpU9fsxOE=
|
||||
github.com/disintegration/imaging v1.6.1/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
github.com/axllent/ghru/v2 v2.0.1 h1:/9XHbMqJRGRxlyNMq2XiHQpBLz7hlq8xCdgJ4ZhjCoM=
|
||||
github.com/axllent/ghru/v2 v2.0.1/go.mod h1:seMMjx8/0r5ZAL7c0vwTPIRoyN0AoTUqAylZEWZWGK4=
|
||||
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
|
||||
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
|
105
goptimize.go
105
goptimize.go
@ -2,23 +2,23 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
"golang.org/x/image/bmp"
|
||||
"golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
// Goptimize downscales and optimizes an existing image
|
||||
func Goptimize(file string) {
|
||||
func goptimize(file string) {
|
||||
info, err := os.Stat(file)
|
||||
|
||||
if err != nil {
|
||||
@ -31,12 +31,21 @@ func Goptimize(file string) {
|
||||
return
|
||||
}
|
||||
|
||||
// open original, rotate if necessary
|
||||
src, err := imaging.Open(file, imaging.AutoOrientation(true))
|
||||
var src image.Image
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v (%s)\n", err, file)
|
||||
return
|
||||
if !copyExif {
|
||||
// rotate if necessary
|
||||
src, err = imaging.Open(file, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v (%s)\n", err, file)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
src, err = imaging.Open(file)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v (%s)\n", err, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
format, err := imaging.FormatFromFilename(file)
|
||||
@ -48,7 +57,7 @@ func Goptimize(file string) {
|
||||
|
||||
if format.String() == "GIF" {
|
||||
// return if GIF is animated - unsupported
|
||||
if err := IsGIFAnimated(file); err != nil {
|
||||
if err := isGIFAnimated(file); err != nil {
|
||||
fmt.Printf("Error: animated GIF not supported (%v)\n", file)
|
||||
return
|
||||
}
|
||||
@ -83,14 +92,14 @@ func Goptimize(file string) {
|
||||
resultW := dstBounds.Dx()
|
||||
resultH := dstBounds.Dy()
|
||||
|
||||
tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-")
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "Goptimized-")
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: cannot create temporary file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
|
||||
switch imgType := format.String(); imgType {
|
||||
case "JPEG":
|
||||
@ -118,25 +127,35 @@ func Goptimize(file string) {
|
||||
|
||||
// immediately close the temp file to release pointers
|
||||
// so we can modify it with system processes
|
||||
tmpFile.Close()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
fmt.Printf("Error closing temporary file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// run through optimizers
|
||||
if format.String() == "JPEG" {
|
||||
// run one or the other, running both has no advantage
|
||||
if jpegtran != "" {
|
||||
RunOptimizer(tmpFilename, true, jpegtran, "-optimize", "-outfile")
|
||||
runOptimizer(tmpFilename, true, jpegtran, "-optimize", "-outfile")
|
||||
} else if jpegoptim != "" {
|
||||
RunOptimizer(tmpFilename, false, jpegoptim, "-f", "-s", "-o")
|
||||
runOptimizer(tmpFilename, false, jpegoptim, "-f", "-s", "-o")
|
||||
}
|
||||
|
||||
if copyExif {
|
||||
if err := exifCopy(file, tmpFilename); err != nil {
|
||||
fmt.Printf("Error copying exif data: %v (%s)\n", err, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if format.String() == "PNG" {
|
||||
if pngquant != "" {
|
||||
RunOptimizer(tmpFilename, true, pngquant, "-f", "--output")
|
||||
runOptimizer(tmpFilename, true, pngquant, "-f", "--output")
|
||||
}
|
||||
if optipng != "" {
|
||||
RunOptimizer(tmpFilename, true, optipng, "-out")
|
||||
runOptimizer(tmpFilename, true, optipng, "-out")
|
||||
}
|
||||
} else if format.String() == "GIF" && gifsicle != "" {
|
||||
RunOptimizer(tmpFilename, true, gifsicle, "-o")
|
||||
runOptimizer(tmpFilename, true, gifsicle, "-o")
|
||||
}
|
||||
|
||||
// re-open modified temporary file
|
||||
@ -146,7 +165,7 @@ func Goptimize(file string) {
|
||||
return
|
||||
}
|
||||
|
||||
defer tmpFile.Close()
|
||||
defer func() { _ = tmpFile.Close() }()
|
||||
|
||||
// get th eoriginal file stats
|
||||
srcStat, _ := os.Stat(file)
|
||||
@ -171,7 +190,7 @@ func Goptimize(file string) {
|
||||
return
|
||||
}
|
||||
|
||||
defer out.Close()
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
if _, err := io.Copy(out, tmpFile); err != nil {
|
||||
fmt.Printf("Error overwriting original file: %v\n", err)
|
||||
@ -185,22 +204,26 @@ func Goptimize(file string) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Goptimized %s (%dx%d %s > %s %v%%)\n", dstFile, resultW, resultH, ByteCountSI(srcSize), ByteCountSI(dstSize), savedPercent)
|
||||
fmt.Printf("Optimized %s (%dx%d %s > %s %v%%)\n", dstFile, resultW, resultH, byteCountSI(srcSize), byteCountSI(dstSize), savedPercent)
|
||||
} else {
|
||||
// if the output directory is not the same,
|
||||
// then write a copy of the original file
|
||||
if outputDir != "" {
|
||||
out, err := os.Create(dstFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating new file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
orig, err := os.Open(file)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening original file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer out.Close()
|
||||
|
||||
orig, _ := os.Open(file)
|
||||
|
||||
defer orig.Close()
|
||||
defer func() { _ = orig.Close() }()
|
||||
|
||||
if _, err := io.Copy(out, orig); err != nil {
|
||||
fmt.Printf("Error ovewriting original file: %v\n", err)
|
||||
@ -214,36 +237,35 @@ func Goptimize(file string) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Copied %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0)
|
||||
fmt.Printf("Copied %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, byteCountSI(srcSize), 0)
|
||||
} else {
|
||||
// we didn't actually change anything
|
||||
fmt.Printf("Skipped %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, ByteCountSI(srcSize), 0)
|
||||
fmt.Printf("Skipped %s (%dx%d %s %v%%)\n", dstFile, srcW, srcH, byteCountSI(srcSize), 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// RunOptimizer will run the specified command on a copy of the temporary file,
|
||||
// runOptimizer will run the specified command on a copy of the temporary file,
|
||||
// and overwrite it if the output is smaller than the original
|
||||
func RunOptimizer(src string, outFileArg bool, args ...string) {
|
||||
func runOptimizer(src string, outFileArg bool, args ...string) {
|
||||
// create a new temp file
|
||||
tmpFile, err := ioutil.TempFile(os.TempDir(), "Goptimized-")
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "goptimize-")
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Cannot create temporary file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
|
||||
source, err := os.Open(src)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Cannot open temporary file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer source.Close()
|
||||
defer func() { _ = source.Close() }()
|
||||
|
||||
if _, err := io.Copy(tmpFile, source); err != nil {
|
||||
fmt.Printf("Cannot copy source file: %v\n", err)
|
||||
@ -273,8 +295,8 @@ func RunOptimizer(src string, outFileArg bool, args ...string) {
|
||||
dstSize := dstStat.Size()
|
||||
|
||||
// ensure file pointers are closed before renaming
|
||||
tmpFile.Close()
|
||||
source.Close()
|
||||
func() { _ = tmpFile.Close() }()
|
||||
func() { _ = source.Close() }()
|
||||
|
||||
if dstSize < srcSize {
|
||||
if err := os.Rename(tmpFilename, src); err != nil {
|
||||
@ -285,7 +307,7 @@ func RunOptimizer(src string, outFileArg bool, args ...string) {
|
||||
}
|
||||
|
||||
// ByteCountSI returns a human readable size from int64 bytes
|
||||
func ByteCountSI(b int64) string {
|
||||
func byteCountSI(b int64) string {
|
||||
const unit = 1000
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%dB", b)
|
||||
@ -299,9 +321,12 @@ func ByteCountSI(b int64) string {
|
||||
}
|
||||
|
||||
// IsGIFAnimated will return an error if the GIF file has more than 1 frame
|
||||
func IsGIFAnimated(gifFile string) error {
|
||||
file, _ := os.Open(gifFile)
|
||||
defer file.Close()
|
||||
func isGIFAnimated(gifFile string) error {
|
||||
file, err := os.Open(gifFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open GIF file: %v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
g, err := gif.DecodeAll(file)
|
||||
if err != nil {
|
||||
@ -313,5 +338,5 @@ func IsGIFAnimated(gifFile string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Animated gif")
|
||||
return fmt.Errorf("cannot optimize an animated gif")
|
||||
}
|
||||
|
107
main.go
107
main.go
@ -1,3 +1,4 @@
|
||||
// Package main is the main application
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -5,9 +6,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/axllent/ghru"
|
||||
"github.com/axllent/ghru/v2"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@ -22,7 +25,16 @@ var (
|
||||
optipng string
|
||||
pngquant string
|
||||
gifsicle string
|
||||
copyExif bool
|
||||
threads = 1
|
||||
version = "dev"
|
||||
// ghruConf is the configuration for the ghru package
|
||||
ghruConf = ghru.Config{
|
||||
Repo: "axllent/goptimize",
|
||||
ArchiveName: "goptimize-{{.OS}}-{{.Arch}}",
|
||||
BinaryName: "goptimize",
|
||||
CurrentVersion: version,
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -42,24 +54,26 @@ func main() {
|
||||
fmt.Printf(" %s -o out/ -q 90 -m 1600x1600 *.jpg\n", os.Args[0])
|
||||
|
||||
fmt.Println("\nDetected optimizers:")
|
||||
if err := displayDelectedOptimizer("jpegtran ", jpegtran); err != nil {
|
||||
displayDelectedOptimizer("jpegoptim", jpegoptim)
|
||||
if err := displayDetectedOptimizer("jpegtran ", jpegtran); err != nil {
|
||||
_ = displayDetectedOptimizer("jpegoptim", jpegoptim)
|
||||
}
|
||||
displayDelectedOptimizer("optipng ", optipng)
|
||||
displayDelectedOptimizer("pngquant ", pngquant)
|
||||
displayDelectedOptimizer("gifsicle ", gifsicle)
|
||||
_ = displayDetectedOptimizer("optipng ", optipng)
|
||||
_ = displayDetectedOptimizer("pngquant ", pngquant)
|
||||
_ = displayDetectedOptimizer("gifsicle ", gifsicle)
|
||||
}
|
||||
|
||||
var maxSizes string
|
||||
var update, showversion, showhelp bool
|
||||
var multiThreaded, update, showVersion, showHelp bool
|
||||
|
||||
flag.IntVarP(&quality, "quality", "q", 75, "quality, JPEG only")
|
||||
flag.StringVarP(&maxSizes, "max", "m", "", "downscale to a maximum width & height in pixels (<width>x<height>)")
|
||||
flag.StringVarP(&outputDir, "out", "o", "", "output directory (default overwrites original)")
|
||||
flag.BoolVarP(&preserveModTimes, "preserve", "p", true, "preserve file modification times")
|
||||
flag.BoolVarP(©Exif, "exif", "e", false, "copy exif data")
|
||||
flag.BoolVarP(&update, "update", "u", false, "update to latest release")
|
||||
flag.BoolVarP(&showversion, "version", "v", false, "show version number")
|
||||
flag.BoolVarP(&showhelp, "help", "h", false, "show help")
|
||||
flag.BoolVarP(&multiThreaded, "threaded", "t", false, "run multi-threaded (use all CPU cores)")
|
||||
flag.BoolVarP(&showVersion, "version", "v", false, "show version number")
|
||||
flag.BoolVarP(&showHelp, "help", "h", false, "show help")
|
||||
|
||||
// third-party optimizers
|
||||
flag.StringVar(&jpegtran, "jpegtran", "jpegtran", "jpegtran binary")
|
||||
@ -71,7 +85,9 @@ func main() {
|
||||
flag.SortFlags = false
|
||||
|
||||
// parse args excluding os.Args[0]
|
||||
flag.Parse(os.Args[1:])
|
||||
if err := flag.Parse(os.Args[1:]); err != nil {
|
||||
fmt.Printf("Error parsing flags: %s\n", err.Error())
|
||||
}
|
||||
|
||||
// detect optimizer paths
|
||||
gifsicle, _ = exec.LookPath(gifsicle)
|
||||
@ -80,28 +96,44 @@ func main() {
|
||||
optipng, _ = exec.LookPath(optipng)
|
||||
pngquant, _ = exec.LookPath(pngquant)
|
||||
|
||||
if showhelp {
|
||||
if showHelp {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if showversion {
|
||||
fmt.Println(fmt.Sprintf("Version: %s", version))
|
||||
latest, _, _, err := ghru.Latest("axllent/goptimize", "goptimize")
|
||||
if err == nil && ghru.GreaterThan(latest, version) {
|
||||
fmt.Printf("Update available: %s\nRun `%s -u` to update.\n", latest, os.Args[0])
|
||||
if showVersion {
|
||||
fmt.Printf("Version: %s\n", version)
|
||||
|
||||
release, err := ghruConf.Latest()
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting latest release: %s\n", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
|
||||
// The latest version is the same version
|
||||
if release.Tag == version {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// A newer release is available
|
||||
fmt.Printf(
|
||||
"Update available: %s\nRun `%s -u` to update (requires read/write access to install directory).\n",
|
||||
release.Tag,
|
||||
os.Args[0],
|
||||
)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if update {
|
||||
rel, err := ghru.Update("axllent/goptimize", "goptimize", version)
|
||||
// Update the app
|
||||
rel, err := ghruConf.SelfUpdate()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
|
||||
return
|
||||
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel.Tag)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if len(flag.Args()) < 1 {
|
||||
@ -137,13 +169,38 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
for _, img := range args {
|
||||
Goptimize(img)
|
||||
if multiThreaded {
|
||||
threads = runtime.NumCPU()
|
||||
}
|
||||
|
||||
processChan := make(chan string)
|
||||
|
||||
wg := &sync.WaitGroup{} // Signal to main goroutine that worker goroutines are working/done working
|
||||
wg.Add(threads)
|
||||
|
||||
for i := 0; i < threads; i++ {
|
||||
go func() {
|
||||
for nextFile := range processChan {
|
||||
goptimize(nextFile)
|
||||
}
|
||||
// Channel was closed, so we finished this goroutine.
|
||||
wg.Done() // Let main goroutine know we are done.
|
||||
}()
|
||||
}
|
||||
|
||||
for _, img := range args {
|
||||
processChan <- img
|
||||
}
|
||||
|
||||
// Close the channel. This tells the worker goroutines that they can be done.
|
||||
close(processChan)
|
||||
|
||||
// Wait for all worker goroutines to finish processing the IPs
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// displayDelectedOptimizer prints whether the optimizer was found
|
||||
func displayDelectedOptimizer(name, bin string) error {
|
||||
// displayDetectedOptimizer prints whether the optimizer was found
|
||||
func displayDetectedOptimizer(name, bin string) error {
|
||||
exe, err := exec.LookPath(bin)
|
||||
if err != nil {
|
||||
return err
|
||||
|
Loading…
x
Reference in New Issue
Block a user