From 265ef4bdf8d5c94054c66fc39bfc324cba741aad Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:16:48 +0200 Subject: [PATCH 01/15] WIP --- go.mod | 29 ++++-- go.sum | 112 +++++++++------------ gui.go | 285 +++++++++++++++++++++++++++++++++++++++++++---------- gui_cli.go | 4 +- 4 files changed, 300 insertions(+), 130 deletions(-) diff --git a/go.mod b/go.mod index 21db181..6771549 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,29 @@ -module github.com/StruffelProductions/simple-deflicker +module github.com/struffel/simple-deflicker -go 1.15 +go 1.24.0 require ( - github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f + gioui.org v0.10.1 github.com/disintegration/imaging v1.6.2 - github.com/gosuri/uilive v0.0.4 // indirect github.com/gosuri/uiprogress v0.0.1 github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 + github.com/ncruces/zenity v0.10.14 +) + +require ( + gioui.org/shader v1.0.8 // indirect + github.com/akavel/rsrc v0.10.2 // indirect + github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect + github.com/go-text/typesetting v0.3.4 // indirect + github.com/gosuri/uilive v0.0.4 // indirect + github.com/josephspurrier/goversioninfo v1.4.1 // indirect github.com/mattn/go-isatty v0.0.12 // indirect - github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect - golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect - golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect + github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.26.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index a529ee4..ad80234 100644 --- a/go.sum +++ b/go.sum @@ -1,82 +1,62 @@ -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -gioui.org v0.0.0-20210106084211-c030065af7bc h1:0jo2QznfZl35y/Bzqt915yUT1ruaTjmC24iJM/4vA94= -gioui.org v0.0.0-20210106084211-c030065af7bc/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= -github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= -github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f h1:lBc+GomHbMn3kT04rRj0P2L1ky5RzqmiiAhERGp2848= -github.com/aarzilli/nucular v0.0.0-20210203155122-9112adf75b4f/go.mod h1:QBibXlOFoxEpCak/TJENqnJo8+Pgp1VS06V2Rf/gSQQ= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0= +gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= +github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745 h1:0d9whnMsm0iklqvoBXNEgHPt8pkXdfDplBAswA/F8YA= -github.com/golang/freetype v0.0.0-20161208064710-d9be45aaf745/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= github.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw= github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= -github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad h1:eMxs9EL0PvIGS9TTtxg4R+JxuPGav82J8rA+GFnY7po= -github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 h1:fO9A67/izFYFYky7l1pDP5Dr0BTCRkaQJUG6Jm5ehsk= github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3/go.mod h1:Ey4uAp+LvIl+s5jRbOHLcZpUDnkjLBROl15fZLwPlTM= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= +github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= +github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/schollz/progressbar v1.0.0/go.mod h1:/l9I7PC3L3erOuz54ghIRKUEFcosiWfLvJv+Eq26UMs= -github.com/schollz/progressbar/v3 v3.7.4/go.mod h1:1H8m5kMPW6q5fyjpDqtBHW1JT22mu2NwHQ1ApuCPh/8= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d h1:Chay1rwJnXxI27H+pzu7P81BKf647un9GOoRPTdXN18= -github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= +github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= +github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= -golang.org/x/exp v0.0.0-20191224044220-1fea468a75e9 h1:HLuLY2KniBsHW28uXd1i2UZKjifeJUy//P/wTK6AJwI= -golang.org/x/exp v0.0.0-20191224044220-1fea468a75e9/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= -golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gui.go b/gui.go index 91198b6..f8920d6 100644 --- a/gui.go +++ b/gui.go @@ -1,77 +1,254 @@ -//go:build !darwin && !cli -// +build !darwin,!cli +//go:build !cli +// +build !cli package main import ( "fmt" - "image" + "os" "path/filepath" + "strconv" - "github.com/aarzilli/nucular/label" - "github.com/aarzilli/nucular/style" + "gioui.org/app" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" - "github.com/aarzilli/nucular" - "github.com/sqweek/dialog" + "github.com/ncruces/zenity" ) -var window = nucular.NewMasterWindowSize(0, "Simple Deflicker", image.Point{450, 450}, windowUpdateFunction) -var sourceField nucular.TextEditor -var destinationField nucular.TextEditor +// guiState holds all the widgets and channels used to communicate results +// from background goroutines (file dialogs, processing) back to the single +// goroutine that owns the Gio window and its widgets. +type guiState struct { + sourceEditor widget.Editor + destinationEditor widget.Editor + rollingAvgEditor widget.Editor + jpegQualityEditor widget.Editor + threadsEditor widget.Editor + + browseSourceBtn widget.Clickable + browseDestinationBtn widget.Clickable + startBtn widget.Clickable + + sourceResult chan string + destinationResult chan string + deflickerResult chan error + + processing bool + statusText string +} + +func newGuiState() *guiState { + state := &guiState{ + sourceResult: make(chan string, 1), + destinationResult: make(chan string, 1), + deflickerResult: make(chan error, 1), + } + state.sourceEditor.SingleLine = true + state.sourceEditor.SetText(config.sourceDirectory) + + state.destinationEditor.SingleLine = true + state.destinationEditor.SetText(config.destinationDirectory) + + state.rollingAvgEditor.SingleLine = true + state.rollingAvgEditor.Filter = "0123456789" + state.rollingAvgEditor.SetText(strconv.Itoa(config.rollingAverage)) + + state.jpegQualityEditor.SingleLine = true + state.jpegQualityEditor.Filter = "0123456789" + state.jpegQualityEditor.SetText(strconv.Itoa(config.jpegCompression)) + + state.threadsEditor.SingleLine = true + state.threadsEditor.Filter = "0123456789" + state.threadsEditor.SetText(strconv.Itoa(config.threads)) + return state +} func startGUI() error { - initalizeWindow() - window.Main() + go func() { + w := new(app.Window) + w.Option(app.Title("Simple Deflicker"), app.Size(unit.Dp(480), unit.Dp(420))) + if err := runWindow(w); err != nil { + fmt.Println(err) + os.Exit(1) + } + os.Exit(0) + }() + app.Main() return nil } -func initalizeWindow() { - window.SetStyle(style.FromTheme(style.DarkTheme, 1.0)) - sourceField.Flags = nucular.EditSelectable | nucular.EditClipboard | nucular.EditSigEnter | nucular.EditIbeamCursor - sourceField.SingleLine = true - sourceField.Buffer = []rune(config.sourceDirectory) +func runWindow(w *app.Window) error { + theme := material.NewTheme() + state := newGuiState() + + var ops op.Ops + for { + switch e := w.Event().(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + receiveGuiResults(state) + handleGuiEvents(gtx, w, state) + layoutGui(gtx, theme, state) + e.Frame(gtx.Ops) + } + } +} + +// receiveGuiResults applies any results delivered by background goroutines +// (directory pickers, deflickering) to the widget state. It must only be +// called from the goroutine that owns the window. +func receiveGuiResults(state *guiState) { + select { + case dir := <-state.sourceResult: + state.sourceEditor.SetText(filepath.ToSlash(dir)) + default: + } + select { + case dir := <-state.destinationResult: + state.destinationEditor.SetText(filepath.ToSlash(dir)) + default: + } + select { + case err := <-state.deflickerResult: + state.processing = false + if err != nil { + state.statusText = "An error occurred: " + err.Error() + go zenity.Error(err.Error(), zenity.Title("Simple Deflicker - Error")) + } else { + state.statusText = "Saved pictures into " + config.destinationDirectory + go zenity.Info(state.statusText, zenity.Title("Simple Deflicker")) + } + default: + } +} - destinationField.Flags = nucular.EditSelectable | nucular.EditClipboard | nucular.EditSigEnter | nucular.EditIbeamCursor - destinationField.SingleLine = true - destinationField.Buffer = []rune(config.destinationDirectory) +func handleGuiEvents(gtx layout.Context, w *app.Window, state *guiState) { + if state.browseSourceBtn.Clicked(gtx) { + go func() { + dir, err := zenity.SelectFile(zenity.Directory(), zenity.Title("Select a source directory.")) + if err == nil { + state.sourceResult <- dir + w.Invalidate() + } + }() + } + if state.browseDestinationBtn.Clicked(gtx) { + go func() { + dir, err := zenity.SelectFile(zenity.Directory(), zenity.Title("Select a destination directory.")) + if err == nil { + state.destinationResult <- dir + w.Invalidate() + } + }() + } + if state.startBtn.Clicked(gtx) && !state.processing { + startDeflickering(w, state) + } } -func windowUpdateFunction(w *nucular.Window) { - //Source Directory - w.Row(25).Dynamic(1) - w.Label("Source Directory", "LB") - sourceField.Edit(w) - sourceField.Buffer = []rune(filepath.ToSlash(string(sourceField.Buffer))) - w.Row(25).Ratio(0.333) - if w.ButtonText("Browse") { - directory, _ := dialog.Directory().Title("Select a source directory.").Browse() - sourceField.Buffer = []rune(filepath.ToSlash(directory)) +// startDeflickering reads the current widget values into config, then runs +// the deflickering process in the background while a zenity progress dialog +// gives the user feedback. +func startDeflickering(w *app.Window, state *guiState) { + config.sourceDirectory = filepath.ToSlash(state.sourceEditor.Text()) + config.destinationDirectory = filepath.ToSlash(state.destinationEditor.Text()) + if v, err := strconv.Atoi(state.rollingAvgEditor.Text()); err == nil { + config.rollingAverage = v + } + if v, err := strconv.Atoi(state.jpegQualityEditor.Text()); err == nil { + config.jpegCompression = v } - w.Row(25).Dynamic(1) - //Destination Directory - w.Label("Destination Directory", "LB") - destinationField.Edit(w) - destinationField.Buffer = []rune(filepath.ToSlash(string(destinationField.Buffer))) - w.Row(25).Ratio(0.333) - if w.ButtonText("Browse") { - directory, _ := dialog.Directory().Title("Select a destination directory.").Browse() - destinationField.Buffer = []rune(filepath.ToSlash(directory)) + if v, err := strconv.Atoi(state.threadsEditor.Text()); err == nil { + config.threads = v } - w.Row(25).Dynamic(1) - w.Label("Advanced settings", "LB") - w.PropertyInt("Rolling average", 0, &config.rollingAverage, 100, 1, 1) - w.PropertyInt("JPEG quality", 1, &config.jpegCompression, 100, 1, 1) - w.PropertyInt("Threads", 1, &config.threads, 128, 1, 1) - w.Row(35).Dynamic(1) - if w.Button(label.T("Start"), false) { - w.Label("TEST", "LB") - config.sourceDirectory = string(sourceField.Buffer) - config.destinationDirectory = string(destinationField.Buffer) - deflickeringError := runDeflickering() - if deflickeringError != nil { - clear() - fmt.Println("An error occurred:") - fmt.Println(deflickeringError) + + state.processing = true + state.statusText = "Processing..." + + go func() { + dlg, dlgErr := zenity.Progress( + zenity.Title("Simple Deflicker"), + zenity.Pulsate(), + zenity.NoCancel(), + ) + if dlgErr == nil { + dlg.Text("Processing images, this can take a while...") } + + err := runDeflickering() + + if dlgErr == nil { + dlg.Complete() + dlg.Close() + } + + state.deflickerResult <- err + w.Invalidate() + }() +} + +func layoutGui(gtx layout.Context, th *material.Theme, state *guiState) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(th, "Source Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Button(th, &state.browseSourceBtn, "Browse").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + layout.Rigid(material.Body1(th, "Destination Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Button(th, &state.browseDestinationBtn, "Browse").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + layout.Rigid(material.Body1(th, "Advanced settings").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, labeledEditor(th, "JPEG quality", &state.jpegQualityEditor)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, labeledEditor(th, "Threads", &state.threadsEditor)), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + text := "Start" + if state.processing { + text = "Processing..." + } + return material.Button(th, &state.startBtn, text).Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body2(th, state.statusText).Layout), + ) + }) +} + +func labeledEditor(th *material.Theme, label string, ed *widget.Editor) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Caption(th, label).Layout), + layout.Rigid(fullWidthBorderedEditor(th, ed)), + ) + } +} + +func fullWidthBorderedEditor(th *material.Theme, ed *widget.Editor) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + border := widget.Border{Color: th.Fg, Width: unit.Dp(1), CornerRadius: unit.Dp(4)} + return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(6)).Layout(gtx, material.Editor(th, ed, "").Layout) + }) } } diff --git a/gui_cli.go b/gui_cli.go index 6dd39ff..63ab161 100644 --- a/gui_cli.go +++ b/gui_cli.go @@ -1,5 +1,5 @@ -//go:build darwin || cli -// +build darwin cli +//go:build cli +// +build cli package main From b77c869f01845c848b4e0d9cd96a859d92e733ea Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:51:21 +0200 Subject: [PATCH 02/15] Partial rebuild of CLI version GUI comes next! --- cmd/cli/main.go | 34 ++++++ main.go => cmd/gui/main.go | 24 +---- config.go | 56 ---------- files.go | 39 ------- go.mod | 3 +- go.sum | 2 + histogram.go | 141 ------------------------- internal/deflicker/deflicker.go | 176 ++++++++++++++++++++++++++++++++ internal/deflicker/histogram.go | 150 +++++++++++++++++++++++++++ internal/deflicker/io.go | 67 ++++++++++++ internal/deflicker/settings.go | 64 ++++++++++++ internal/progress/progress.go | 31 ++++++ gui.go => internal/ui/gui.go | 50 +-------- internal/ui/ui.go | 57 +++++++++++ progress.go | 44 -------- util.go | 45 -------- 16 files changed, 586 insertions(+), 397 deletions(-) create mode 100644 cmd/cli/main.go rename main.go => cmd/gui/main.go (86%) delete mode 100644 config.go delete mode 100644 files.go delete mode 100644 histogram.go create mode 100644 internal/deflicker/deflicker.go create mode 100644 internal/deflicker/histogram.go create mode 100644 internal/deflicker/io.go create mode 100644 internal/deflicker/settings.go create mode 100644 internal/progress/progress.go rename gui.go => internal/ui/gui.go (80%) create mode 100644 internal/ui/ui.go delete mode 100644 progress.go delete mode 100644 util.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..30a6d90 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/struffel/simple-deflicker/internal/deflicker" + "github.com/struffel/simple-deflicker/internal/progress" +) + +func main() { + + // Read CLI parameters. + settings := deflicker.NewSettingsFromArgs() + + // In CLI mode, check the settings immediately + validationErrors := settings.Validate() + if len(validationErrors) > 0 { + for _, err := range validationErrors { + fmt.Println(err) + } + os.Exit(1) + } + + // Run the actual deflickering + deflickeringError := deflicker.Run(settings, &progress.ConsoleUpdater{}) + if deflickeringError != nil { + fmt.Println("An error occured:") + fmt.Println(deflickeringError) + os.Exit(1) + } + os.Exit(0) + +} diff --git a/main.go b/cmd/gui/main.go similarity index 86% rename from main.go rename to cmd/gui/main.go index ab31557..0be1d2a 100644 --- a/main.go +++ b/cmd/gui/main.go @@ -9,28 +9,8 @@ import ( "github.com/disintegration/imaging" ) -type picture struct { - currentPath string - targetPath string - currentRgbHistogram rgbHistogram - targetRgbHistogram rgbHistogram -} - func main() { - //Initial console output - printInfo() - //Read parameters from console - config = collectConfigInformation() - if shouldRunCli(config) { - deflickeringError := runDeflickering() - if deflickeringError != nil { - clear() - fmt.Println("An error occured:") - fmt.Println(deflickeringError) - os.Exit(1) - } - os.Exit(0) - } + //Initialize Window from config and start GUI guiError := startGUI() if guiError != nil { @@ -137,6 +117,6 @@ func runDeflickering() error { } progress.container.Stop() clear() - fmt.Printf("Saved %v pictures into %v", len(pictures),config.destinationDirectory) + fmt.Printf("Saved %v pictures into %v", len(pictures), config.destinationDirectory) return nil } diff --git a/config.go b/config.go deleted file mode 100644 index 1912282..0000000 --- a/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "errors" - "flag" - "runtime" -) - -type configuration struct { - sourceDirectory string - destinationDirectory string - rollingAverage int - jpegCompression int - threads int -} - -var config configuration - -func collectConfigInformation() configuration { - var config configuration - flag.StringVar(&config.sourceDirectory, "source", "", "Directory with the images to process.") - flag.StringVar(&config.destinationDirectory, "destination", "", "Directory to put the processed images in.") - flag.IntVar(&config.rollingAverage, "rollingAverage", 15, "Number of frames to use for rolling average. 0 disables it.") - flag.IntVar(&config.jpegCompression, "jpegCompression", 95, "Level of JPEG compression. Must be between 1 - 100. Default is 95.") - flag.IntVar(&config.threads, "threads", runtime.NumCPU(), "Number of threads to use. Default is the detected number of cores.") - flag.Parse() - return config -} -func validateConfigInformation() error { - description := "" - //Test for illegal inputs - if config.jpegCompression < 1 || config.jpegCompression > 100 { - description += "Invalid JPEG compression setting. Value must be between 1 and 100 (inclusive).\n" - } - if config.threads < 1 { - description += "Invalid number of threads. There must be at least one thread.\n" - } - if config.rollingAverage < 0 { - description += "Invalid rolling average. Value must be equal to or greater than 1.\n" - } - if config.sourceDirectory == "" { - description += "No source directory specified.\n" - } else if !testForDirectory(config.sourceDirectory) { - description += "The source directory could not be found.\n" - } - if config.destinationDirectory == "" { - description += "No destination directory specified.\n" - } else if !testForDirectory(config.destinationDirectory) { - description += "The destination directory could not be found.\n" - } - if description != "" { - return errors.New(description) - } else { - return nil - } -} diff --git a/files.go b/files.go deleted file mode 100644 index b7a04dc..0000000 --- a/files.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "errors" - "io/ioutil" - "os" - "path/filepath" - "strings" -) - -func readDirectory(currentDirectory string, targetDirectory string) ([]picture, error) { - var pictures []picture - //Get list of files - files, err := ioutil.ReadDir(currentDirectory) - if err != nil { - return pictures, err - } - //Prepare slice of pictures - for _, file := range files { - var fullSourcePath = filepath.Join(currentDirectory, file.Name()) - var fullTargetPath = filepath.Join(targetDirectory, file.Name()) - var extension = strings.ToLower(filepath.Ext(file.Name())) - var temp rgbHistogram - if extension == ".jpg" || extension == ".png" { - pictures = append(pictures, picture{fullSourcePath, fullTargetPath, temp, temp}) - } - } - if len(pictures) < 1 { - return pictures, errors.New("the source directory does not contain any compatible images (JPG or PNG)") - } - return pictures, nil -} - -func testForDirectory(directory string) bool { - if _, err := os.Stat(directory); os.IsNotExist(err) { - return false - } - return true -} diff --git a/go.mod b/go.mod index 6771549..ac62582 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/struffel/simple-deflicker -go 1.24.0 +go 1.25.0 require ( gioui.org v0.10.1 @@ -23,6 +23,7 @@ require ( golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/image v0.26.0 // indirect golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index ad80234..20cc7bc 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/histogram.go b/histogram.go deleted file mode 100644 index 3200044..0000000 --- a/histogram.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "image" - "image/color" - "math" - - "github.com/disintegration/imaging" -) - -type lut [256]uint8 -type rgbLut struct { - r lut - g lut - b lut -} -type histogram [256]uint32 -type rgbHistogram struct { - r histogram - g histogram - b histogram -} - -const ( - lutCorrectionStrength = 0.8 - lutSmoothingRadius = 5 -) - -func generateRgbHistogramFromImage(input image.Image) rgbHistogram { - var rgbHistogram rgbHistogram - for y := input.Bounds().Min.Y; y < input.Bounds().Max.Y; y++ { - for x := input.Bounds().Min.X; x < input.Bounds().Max.X; x++ { - r, g, b, _ := input.At(x, y).RGBA() - r = r >> 8 - g = g >> 8 - b = b >> 8 - rgbHistogram.r[r]++ - rgbHistogram.g[g]++ - rgbHistogram.b[b]++ - } - } - return rgbHistogram -} - -func convertToCumulativeRgbHistogram(input rgbHistogram) rgbHistogram { - var targetRgbHistogram rgbHistogram - targetRgbHistogram.r[0] = input.r[0] - targetRgbHistogram.g[0] = input.g[0] - targetRgbHistogram.b[0] = input.b[0] - for i := 1; i < 256; i++ { - targetRgbHistogram.r[i] = targetRgbHistogram.r[i-1] + input.r[i] - targetRgbHistogram.g[i] = targetRgbHistogram.g[i-1] + input.g[i] - targetRgbHistogram.b[i] = targetRgbHistogram.b[i-1] + input.b[i] - } - return targetRgbHistogram -} - -func generateRgbLutFromRgbHistograms(current rgbHistogram, target rgbHistogram) rgbLut { - currentCumulativeRgbHistogram := convertToCumulativeRgbHistogram(current) - targetCumulativeRgbHistogram := convertToCumulativeRgbHistogram(target) - var ratio [3]float64 - ratio[0] = float64(currentCumulativeRgbHistogram.r[255]) / float64(targetCumulativeRgbHistogram.r[255]) - ratio[1] = float64(currentCumulativeRgbHistogram.g[255]) / float64(targetCumulativeRgbHistogram.g[255]) - ratio[2] = float64(currentCumulativeRgbHistogram.b[255]) / float64(targetCumulativeRgbHistogram.b[255]) - for i := 0; i < 256; i++ { - targetCumulativeRgbHistogram.r[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.r[i])*ratio[0]) - targetCumulativeRgbHistogram.g[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.g[i])*ratio[1]) - targetCumulativeRgbHistogram.b[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.b[i])*ratio[2]) - } - - //Generate LUT - var lut rgbLut - var p [3]uint8 - for i := 0; i < 256; i++ { - for targetCumulativeRgbHistogram.r[p[0]] < currentCumulativeRgbHistogram.r[i] { - p[0]++ - } - for targetCumulativeRgbHistogram.g[p[1]] < currentCumulativeRgbHistogram.g[i] { - p[1]++ - } - for targetCumulativeRgbHistogram.b[p[2]] < currentCumulativeRgbHistogram.b[i] { - p[2]++ - } - lut.r[i] = p[0] - lut.g[i] = p[1] - lut.b[i] = p[2] - } - lut.r = regularizeLut(lut.r) - lut.g = regularizeLut(lut.g) - lut.b = regularizeLut(lut.b) - return lut -} - -func applyRgbLutToImage(input image.Image, lut rgbLut) image.Image { - result := imaging.AdjustFunc(input, func(c color.NRGBA) color.NRGBA { - c.R = uint8(lut.r[c.R]) - c.G = uint8(lut.g[c.G]) - c.B = uint8(lut.b[c.B]) - return c - }) - return result -} - -func regularizeLut(input lut) lut { - var smoothed lut - for i := 0; i < 256; i++ { - sum := 0 - count := 0 - for j := i - lutSmoothingRadius; j <= i+lutSmoothingRadius; j++ { - if j < 0 || j > 255 { - continue - } - sum += int(input[j]) - count++ - } - smoothed[i] = uint8(sum / count) - } - for i := 1; i < 256; i++ { - if smoothed[i] < smoothed[i-1] { - smoothed[i] = smoothed[i-1] - } - } - - var result lut - for i := 0; i < 256; i++ { - corrected := float64(smoothed[i])*lutCorrectionStrength + float64(i)*(1-lutCorrectionStrength) - result[i] = clampUint8(corrected) - } - return result -} - -func clampUint8(value float64) uint8 { - value = math.Round(value) - if value < 0 { - return 0 - } - if value > 255 { - return 255 - } - return uint8(value) -} diff --git a/internal/deflicker/deflicker.go b/internal/deflicker/deflicker.go new file mode 100644 index 0000000..b248d9d --- /dev/null +++ b/internal/deflicker/deflicker.go @@ -0,0 +1,176 @@ +package deflicker + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + + "github.com/struffel/simple-deflicker/internal/progress" + "golang.org/x/sync/errgroup" +) + +type PictureInfo struct { + Name string + OriginalRgbHistogram RgbHistogram + DesiredRgbHistogram RgbHistogram +} + +func Run(settings Settings, updater progress.Updater) error { + + // Validate config information + settingsErrors := settings.Validate() + if len(settingsErrors) > 0 { + return fmt.Errorf("Settings validation failed: %v", settingsErrors) + } + + updater.Start() + + // Read the entire source directory and create Picture structs with histograms + pictures, err := GetSourcePictureInfo(settings.SourceDirectory, updater) + if err != nil { + return err + } + + err = FillDesiredHistograms(&pictures, updater, settings.RollingAverage) + if err != nil { + return err + } + + AdjustImages(&pictures, settings, updater) + + updater.Finish() + return nil +} + +func AdjustImages(pictures *[]PictureInfo, settings Settings, updater progress.Updater) error { + total := len(*pictures) + var completed atomic.Int32 + + var g errgroup.Group + g.SetLimit(runtime.NumCPU()) + + for i := range *pictures { + g.Go(func() error { + sourcePath := filepath.Join(settings.SourceDirectory, (*pictures)[i].Name) + destinationPath := filepath.Join(settings.DestinationDirectory, (*pictures)[i].Name) + + sourceImage, err := ReadImage(sourcePath) + if err != nil { + return err + } + + destinationLut := GenerateRgbLutFromRgbHistograms((*pictures)[i].OriginalRgbHistogram, (*pictures)[i].DesiredRgbHistogram) + destinationImage := ApplyRgbLutToImage(sourceImage, destinationLut) + + if err := SaveImage(destinationImage, destinationPath, settings.OutFormat, settings.JpegQuality); err != nil { + return err + } + + updater.Increment((*pictures)[i].Name, "Adjusting image", int(completed.Add(1)), total) + return nil + }) + } + return g.Wait() +} + +func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, rollingAverage int) error { + + if rollingAverage < 1 { + var averageRgbHistogram RgbHistogram + for i := range *pictures { + for j := 0; j < 256; j++ { + averageRgbHistogram.R[j] += (*pictures)[i].OriginalRgbHistogram.R[j] + averageRgbHistogram.G[j] += (*pictures)[i].OriginalRgbHistogram.G[j] + averageRgbHistogram.B[j] += (*pictures)[i].OriginalRgbHistogram.B[j] + } + //updater.Increment((*pictures)[i].Name, "Calculating average histogram", i+1, len(*pictures)) + } + for i := 0; i < 256; i++ { + averageRgbHistogram.R[i] /= uint32(len(*pictures)) + averageRgbHistogram.G[i] /= uint32(len(*pictures)) + averageRgbHistogram.B[i] /= uint32(len(*pictures)) + } + for i := range *pictures { + (*pictures)[i].DesiredRgbHistogram = averageRgbHistogram + } + } else { + for i := range *pictures { + var averageRgbHistogram RgbHistogram + var start = i - rollingAverage + if start < 0 { + start = 0 + } + var end = i + rollingAverage + if end > len(*pictures)-1 { + end = len(*pictures) - 1 + } + for j := start; j <= end; j++ { + for k := 0; k < 256; k++ { + averageRgbHistogram.R[k] += (*pictures)[j].OriginalRgbHistogram.R[k] + averageRgbHistogram.G[k] += (*pictures)[j].OriginalRgbHistogram.G[k] + averageRgbHistogram.B[k] += (*pictures)[j].OriginalRgbHistogram.B[k] + } + } + for k := 0; k < 256; k++ { + averageRgbHistogram.R[k] /= uint32(end - start + 1) + averageRgbHistogram.G[k] /= uint32(end - start + 1) + averageRgbHistogram.B[k] /= uint32(end - start + 1) + } + (*pictures)[i].DesiredRgbHistogram = averageRgbHistogram + //updater.Increment((*pictures)[i].Name, "Calculating rolling average histogram", i+1, len(*pictures)) + } + } + + return nil +} + +func GetSourcePictureInfo(directory string, updater progress.Updater) ([]PictureInfo, error) { + + // Get raw list of files + files, err := os.ReadDir(directory) + if err != nil { + return nil, err + } + + // Filter down to the compatible image file names, preserving directory order + var imageNames []string + for _, file := range files { + extension := strings.ToLower(filepath.Ext(file.Name())) + if extension == ".jpg" || extension == ".png" { + imageNames = append(imageNames, file.Name()) + } + } + if len(imageNames) < 1 { + return nil, errors.New("the source directory does not contain any compatible images (JPG or PNG)") + } + + totalFiles := len(imageNames) + pictures := make([]PictureInfo, totalFiles) + var completed atomic.Int32 + + var g errgroup.Group + g.SetLimit(runtime.NumCPU()) + + // Calculate histograms concurrently, writing results into their original index to preserve order + for index, name := range imageNames { + g.Go(func() error { + image, err := ReadImage(filepath.Join(directory, name)) + if err != nil { + return err + } + histogram := GenerateRgbHistogramFromImage(image) + pictures[index] = PictureInfo{Name: name, OriginalRgbHistogram: histogram, DesiredRgbHistogram: RgbHistogram{}} + + updater.Increment(name, "Calculating histogram", int(completed.Add(1)), totalFiles) + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return pictures, nil +} diff --git a/internal/deflicker/histogram.go b/internal/deflicker/histogram.go new file mode 100644 index 0000000..5c8aa6d --- /dev/null +++ b/internal/deflicker/histogram.go @@ -0,0 +1,150 @@ +package deflicker + +import ( + "image" + "image/color" + "math" + + "github.com/disintegration/imaging" +) + +type Lut [256]uint8 +type RgbLut struct { + R Lut + G Lut + B Lut +} +type Histogram [256]uint32 +type RgbHistogram struct { + R Histogram + G Histogram + B Histogram +} + +const ( + lutCorrectionStrength = 0.8 + lutSmoothingRadius = 5 +) + +// GenerateRgbHistogramFromImage generates an RGB histogram from the given image. +func GenerateRgbHistogramFromImage(input image.Image) RgbHistogram { + var rgbHistogram RgbHistogram + for y := input.Bounds().Min.Y; y < input.Bounds().Max.Y; y++ { + for x := input.Bounds().Min.X; x < input.Bounds().Max.X; x++ { + r, g, b, _ := input.At(x, y).RGBA() + r = r >> 8 + g = g >> 8 + b = b >> 8 + rgbHistogram.R[r]++ + rgbHistogram.G[g]++ + rgbHistogram.B[b]++ + } + } + return rgbHistogram +} + +// ConvertToCumulativeRgbHistogram converts a given RGB histogram into a cumulative histogram. +func ConvertToCumulativeRgbHistogram(input RgbHistogram) RgbHistogram { + var targetRgbHistogram RgbHistogram + targetRgbHistogram.R[0] = input.R[0] + targetRgbHistogram.G[0] = input.G[0] + targetRgbHistogram.B[0] = input.B[0] + for i := 1; i < 256; i++ { + targetRgbHistogram.R[i] = targetRgbHistogram.R[i-1] + input.R[i] + targetRgbHistogram.G[i] = targetRgbHistogram.G[i-1] + input.G[i] + targetRgbHistogram.B[i] = targetRgbHistogram.B[i-1] + input.B[i] + } + return targetRgbHistogram +} + +// GenerateRgbLutFromRgbHistograms generates a lookup table (LUT) for each color channel (R, G, B) based on the current and target RGB histograms. +// The LUT is used to map pixel values from the current image to the target image, effectively adjusting the colors to match the desired histogram. +func GenerateRgbLutFromRgbHistograms(current RgbHistogram, target RgbHistogram) RgbLut { + currentCumulativeRgbHistogram := ConvertToCumulativeRgbHistogram(current) + targetCumulativeRgbHistogram := ConvertToCumulativeRgbHistogram(target) + + var ratio [3]float64 + ratio[0] = float64(currentCumulativeRgbHistogram.R[255]) / float64(targetCumulativeRgbHistogram.R[255]) + ratio[1] = float64(currentCumulativeRgbHistogram.G[255]) / float64(targetCumulativeRgbHistogram.G[255]) + ratio[2] = float64(currentCumulativeRgbHistogram.B[255]) / float64(targetCumulativeRgbHistogram.B[255]) + for i := 0; i < 256; i++ { + targetCumulativeRgbHistogram.R[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.R[i])*ratio[0]) + targetCumulativeRgbHistogram.G[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.G[i])*ratio[1]) + targetCumulativeRgbHistogram.B[i] = uint32(0.5 + float64(targetCumulativeRgbHistogram.B[i])*ratio[2]) + } + + //Generate LUT + var lut RgbLut + var p [3]uint8 + for i := 0; i < 256; i++ { + for targetCumulativeRgbHistogram.R[p[0]] < currentCumulativeRgbHistogram.R[i] { + p[0]++ + } + for targetCumulativeRgbHistogram.G[p[1]] < currentCumulativeRgbHistogram.G[i] { + p[1]++ + } + for targetCumulativeRgbHistogram.B[p[2]] < currentCumulativeRgbHistogram.B[i] { + p[2]++ + } + lut.R[i] = p[0] + lut.G[i] = p[1] + lut.B[i] = p[2] + } + lut.R = RegularizeLut(lut.R) + lut.G = RegularizeLut(lut.G) + lut.B = RegularizeLut(lut.B) + return lut +} + +// ApplyRgbLutToImage applies the given RGB lookup table (LUT) to the input image, adjusting its pixel values according to the LUT. +func ApplyRgbLutToImage(input image.Image, lut RgbLut) image.Image { + result := imaging.AdjustFunc(input, func(c color.NRGBA) color.NRGBA { + c.R = uint8(lut.R[c.R]) + c.G = uint8(lut.G[c.G]) + c.B = uint8(lut.B[c.B]) + return c + }) + return result +} + +// RegularizeLut smooths and corrects the given lookup table (LUT) to ensure that it is monotonically increasing and applies a correction strength to the values. +// This helps in preventing abrupt changes in pixel values when applying the LUT to an image. +func RegularizeLut(input Lut) Lut { + var smoothed Lut + for i := 0; i < 256; i++ { + sum := 0 + count := 0 + for j := i - lutSmoothingRadius; j <= i+lutSmoothingRadius; j++ { + if j < 0 || j > 255 { + continue + } + sum += int(input[j]) + count++ + } + smoothed[i] = uint8(sum / count) + } + for i := 1; i < 256; i++ { + if smoothed[i] < smoothed[i-1] { + smoothed[i] = smoothed[i-1] + } + } + + var result Lut + for i := 0; i < 256; i++ { + corrected := float64(smoothed[i])*lutCorrectionStrength + float64(i)*(1-lutCorrectionStrength) + result[i] = ClampUint8(corrected) + } + return result +} + +// ClampUint8 clamps a float64 value to the range of 0 to 255 and returns it as a uint8. +func ClampUint8(value float64) uint8 { + value = math.Round(value) + if value < 0 { + return 0 + } + if value > 255 { + return 255 + } + return uint8(value) +} diff --git a/internal/deflicker/io.go b/internal/deflicker/io.go new file mode 100644 index 0000000..7bf8d55 --- /dev/null +++ b/internal/deflicker/io.go @@ -0,0 +1,67 @@ +package deflicker + +import ( + "errors" + "image" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" +) + +func DirectoryExists(path string) bool { + stat, err := os.Stat(path) + if err == nil && stat.IsDir() { + return true + } + return false +} + +func ListImagesInDirectory(path string) ([]string, error) { + files, err := os.ReadDir(path) + if err != nil { + return nil, err + } + var images []string + for _, file := range files { + extension := strings.ToLower(filepath.Ext(file.Name())) + if extension == ".jpg" || extension == ".png" { + images = append(images, filepath.Join(path, file.Name())) + } + } + return images, nil +} + +func ReadImage(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + return nil, err + } + return img, nil +} + +func SaveImage(img image.Image, path string, format OutputFormat, jpegQuality int) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + switch format { + case FormatJpeg: + err = jpeg.Encode(file, img, &jpeg.Options{Quality: jpegQuality}) + case FormatPng: + err = png.Encode(file, img) + default: + return errors.New("unsupported output format") + } + + return nil +} diff --git a/internal/deflicker/settings.go b/internal/deflicker/settings.go new file mode 100644 index 0000000..1e771bb --- /dev/null +++ b/internal/deflicker/settings.go @@ -0,0 +1,64 @@ +package deflicker + +import ( + "flag" + "fmt" +) + +type OutputFormat string + +const ( + FormatJpeg OutputFormat = "jpeg" + FormatPng OutputFormat = "png" +) + +type Settings struct { + SourceDirectory string + DestinationDirectory string + RollingAverage int + OutFormat OutputFormat + JpegQuality int +} + +func (s *Settings) UseGlobalAverage() bool { + return s.RollingAverage < 1 +} + +func NewSettingsFromArgs() Settings { + var settings Settings + var tmpFormat string + flag.StringVar(&settings.SourceDirectory, "source", "", "Directory with the images to process.") + flag.StringVar(&settings.DestinationDirectory, "destination", "", "Directory to put the processed images in.") + flag.IntVar(&settings.RollingAverage, "rollingAverage", 15, "Number of frames to use for rolling average. 0 disables it.") + flag.StringVar(&tmpFormat, "format", "png", "Output format. Options are jpeg png.") + flag.IntVar(&settings.JpegQuality, "jpegQuality", 95, "Level of JPEG compression. Must be between 1 - 100.") + flag.Parse() + settings.OutFormat = OutputFormat(tmpFormat) + return settings +} + +func (s *Settings) Validate() []error { + errors := []error{} + + if s.JpegQuality < 1 || s.JpegQuality > 100 { + errors = append(errors, fmt.Errorf("Invalid JPEG compression setting. Value must be between 1 and 100 (inclusive).")) + } + if s.RollingAverage < 0 { + errors = append(errors, fmt.Errorf("Invalid rolling average. Value must be equal to or greater than 0, with 0 disabling it.")) + } + if s.OutFormat != FormatJpeg && s.OutFormat != FormatPng { + errors = append(errors, fmt.Errorf("Invalid output format. Options are jpeg png.")) + } + + if s.SourceDirectory == "" { + errors = append(errors, fmt.Errorf("No source directory specified.")) + } else if !DirectoryExists(s.SourceDirectory) { + errors = append(errors, fmt.Errorf("The source directory could not be found.")) + } + if s.DestinationDirectory == "" { + errors = append(errors, fmt.Errorf("No destination directory specified.")) + } else if !DirectoryExists(s.DestinationDirectory) { + errors = append(errors, fmt.Errorf("The destination directory could not be found.")) + } + return errors +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..24ef694 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,31 @@ +package progress + +import "fmt" + +type Updater interface { + Start() + Increment(msg string, phase string, completed int, ofTotal int) + Finish() +} + +// NullUpdater is a dummy implementation of the Updater interface that does nothing. +type NullUpdater struct{} + +func (n *NullUpdater) Start() {} +func (n *NullUpdater) Increment(msg string, phase string, completed int, ofTotal int) {} +func (n *NullUpdater) Finish() {} + +// ConsoleUpdater is an implementation of the Updater interface that prints progress to the console. +type ConsoleUpdater struct{} + +func (c *ConsoleUpdater) Start() { + println("Processing started...") +} + +func (c *ConsoleUpdater) Increment(msg string, phase string, completed int, ofTotal int) { + fmt.Printf("[%s] %s (%d/%d)\n", phase, msg, completed, ofTotal) +} + +func (c *ConsoleUpdater) Finish() { + println("Processing finished.") +} diff --git a/gui.go b/internal/ui/gui.go similarity index 80% rename from gui.go rename to internal/ui/gui.go index f8920d6..17fa736 100644 --- a/gui.go +++ b/internal/ui/gui.go @@ -1,7 +1,7 @@ //go:build !cli // +build !cli -package main +package ui import ( "fmt" @@ -19,54 +19,6 @@ import ( "github.com/ncruces/zenity" ) -// guiState holds all the widgets and channels used to communicate results -// from background goroutines (file dialogs, processing) back to the single -// goroutine that owns the Gio window and its widgets. -type guiState struct { - sourceEditor widget.Editor - destinationEditor widget.Editor - rollingAvgEditor widget.Editor - jpegQualityEditor widget.Editor - threadsEditor widget.Editor - - browseSourceBtn widget.Clickable - browseDestinationBtn widget.Clickable - startBtn widget.Clickable - - sourceResult chan string - destinationResult chan string - deflickerResult chan error - - processing bool - statusText string -} - -func newGuiState() *guiState { - state := &guiState{ - sourceResult: make(chan string, 1), - destinationResult: make(chan string, 1), - deflickerResult: make(chan error, 1), - } - state.sourceEditor.SingleLine = true - state.sourceEditor.SetText(config.sourceDirectory) - - state.destinationEditor.SingleLine = true - state.destinationEditor.SetText(config.destinationDirectory) - - state.rollingAvgEditor.SingleLine = true - state.rollingAvgEditor.Filter = "0123456789" - state.rollingAvgEditor.SetText(strconv.Itoa(config.rollingAverage)) - - state.jpegQualityEditor.SingleLine = true - state.jpegQualityEditor.Filter = "0123456789" - state.jpegQualityEditor.SetText(strconv.Itoa(config.jpegCompression)) - - state.threadsEditor.SingleLine = true - state.threadsEditor.Filter = "0123456789" - state.threadsEditor.SetText(strconv.Itoa(config.threads)) - return state -} - func startGUI() error { go func() { w := new(app.Window) diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..f3bb778 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,57 @@ +package ui + +import ( + "strconv" + + "gioui.org/widget" + "github.com/struffel/simple-deflicker/internal" +) + +type UiState struct { + Settings internal.Settings + + sourceResult chan string + destinationResult chan string + deflickerResult chan error + + processing bool + statusText string + + sourceEditor widget.Editor + destinationEditor widget.Editor + rollingAvgEditor widget.Editor + jpegQualityEditor widget.Editor + threadsEditor widget.Editor + + browseSourceBtn widget.Clickable + browseDestinationBtn widget.Clickable + startBtn widget.Clickable +} + +func NewUiState(settings internal.Settings) *UiState { + state := &UiState{ + sourceResult: make(chan string, 1), + destinationResult: make(chan string, 1), + deflickerResult: make(chan error, 1), + processing: false, + statusText: "", + } + state.sourceEditor.SingleLine = true + state.sourceEditor.SetText(settings.SourceDirectory) + + state.destinationEditor.SingleLine = true + state.destinationEditor.SetText(settings.DestinationDirectory) + + state.rollingAvgEditor.SingleLine = true + state.rollingAvgEditor.Filter = "0123456789" + state.rollingAvgEditor.SetText(strconv.Itoa(settings.RollingAverage)) + + state.jpegQualityEditor.SingleLine = true + state.jpegQualityEditor.Filter = "0123456789" + state.jpegQualityEditor.SetText(strconv.Itoa(settings.JpegCompression)) + + state.threadsEditor.SingleLine = true + state.threadsEditor.Filter = "0123456789" + state.threadsEditor.SetText(strconv.Itoa(settings.Threads)) + return state +} diff --git a/progress.go b/progress.go deleted file mode 100644 index d99651c..0000000 --- a/progress.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "fmt" - "math" - - "github.com/gosuri/uiprogress" -) - -type progressInfo struct { - container *uiprogress.Progress - bars map[string]*uiprogress.Bar -} - -func createProgressBars(numberOfPictures int) progressInfo { - var tmpProgress progressInfo - tmpProgress.container = uiprogress.New() - tmpProgress.bars = make(map[string]*uiprogress.Bar) - tmpProgress.bars["analyze"] = tmpProgress.container.AddBar(numberOfPictures).PrependCompleted().PrependElapsed() - tmpProgress.bars["adjust"] = tmpProgress.container.AddBar(numberOfPictures).PrependCompleted().PrependElapsed() - - tmpProgress.bars["analyze"].Width = 20 - tmpProgress.bars["adjust"].Width = 20 - - progressBarFunction := func(b *uiprogress.Bar, step string) string { - //Calculate the number of digits to display - n := math.Floor(math.Log10(float64(b.Total)) + 1) - f := fmt.Sprintf("%%-15v %%-%vv/%%-%vv", n, n) - return fmt.Sprintf(f, step, b.Current(), b.Total) - } - - progressBarFunctionAnalyze := func(b *uiprogress.Bar) string { - return progressBarFunction(b, "Analyzing") - } - - progressBarFunctionAdjust := func(b *uiprogress.Bar) string { - return progressBarFunction(b, "Adjusting") - } - - tmpProgress.bars["adjust"].AppendFunc(progressBarFunctionAdjust) - tmpProgress.bars["analyze"].AppendFunc(progressBarFunctionAnalyze) - - return tmpProgress -} diff --git a/util.go b/util.go deleted file mode 100644 index ca95ec7..0000000 --- a/util.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/gosuri/uiprogress" - "github.com/inancgumus/screen" -) - -func forEveryPicture(pictures []picture, progressBar *uiprogress.Bar, threads int, f func(pic picture) (picture, error)) ([]picture, error) { - tokens := make(chan error, threads) - var err error - for i := 0; i < threads; i++ { - tokens <- nil - } - for i := range pictures { - err = <-tokens - if err != nil { - return pictures, err - } - go func(i int) { - var functionError error - defer func() { - progressBar.Incr() - tokens <- functionError - }() - pictures[i], functionError = f(pictures[i]) - }(i) - } - for i := 0; i < threads; i++ { - err = <-tokens - if err != nil { - return pictures, err - } - } - return pictures, nil -} -func printInfo() { - fmt.Println("SIMPLE DEFLICKER") - fmt.Println("v0.4.0 / github.com/struffel/simple-deflicker") -} -func clear() { - screen.MoveTopLeft() - screen.Clear() -} From 1cedc2dd7cb2548871d30013f07be9368df6435c Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:22:12 +0200 Subject: [PATCH 03/15] Somewhat working version Several issues with the zenity dialog. May move that into gio as well. --- README.md | 17 ++--- cmd/gui/main.go | 112 +--------------------------- gui_cli.go | 10 --- internal/deflicker/deflicker.go | 6 +- internal/progress/zenity.go | 50 +++++++++++++ internal/ui/gui.go | 128 ++++++++------------------------ internal/ui/layout.go | 104 ++++++++++++++++++++++++++ internal/ui/ui.go | 24 ++++-- 8 files changed, 218 insertions(+), 233 deletions(-) delete mode 100644 gui_cli.go create mode 100644 internal/progress/zenity.go create mode 100644 internal/ui/layout.go diff --git a/README.md b/README.md index 4ca6885..3b86140 100644 --- a/README.md +++ b/README.md @@ -15,30 +15,29 @@ Timelapse flickering can occur if one or more settings of the camera have been l Simple Deflicker can run without the GUI by passing a source and destination directory: ```bash -simple-deflicker -source "/path/to/input" -destination "/path/to/output" +simple-deflicker-cli -source "/path/to/input" -destination "/path/to/output" ``` Optional flags: ```bash -simple-deflicker -source "/path/to/input" -destination "/path/to/output" -rollingAverage 15 -jpegCompression 95 -threads 8 +simple-deflicker-cli -source "/path/to/input" -destination "/path/to/output" -rollingAverage 15 -format png -jpegQuality 95 ``` -Build a CLI-only binary: +The GUI and CLI are separate binaries, built from `./cmd/gui` and `./cmd/cli` respectively: ```bash -go build -tags cli -o simple-deflicker +go build -o simple-deflicker ./cmd/gui +go build -o simple-deflicker-cli ./cmd/cli ``` -macOS builds are CLI-only: +macOS builds only support the CLI binary: ```bash -GOOS=darwin GOARCH=arm64 go build -o simple-deflicker-macos-arm64 -GOOS=darwin GOARCH=amd64 go build -o simple-deflicker-macos-amd64 +GOOS=darwin GOARCH=arm64 go build -o simple-deflicker-cli-macos-arm64 ./cmd/cli +GOOS=darwin GOARCH=amd64 go build -o simple-deflicker-cli-macos-amd64 ./cmd/cli ``` -On platforms built with the `cli` tag, the GUI is disabled and `-source` plus `-destination` are required. - ## Current limitations of the tool * Only JPG and PNG (8bit) are supported diff --git a/cmd/gui/main.go b/cmd/gui/main.go index 0be1d2a..457c3fd 100644 --- a/cmd/gui/main.go +++ b/cmd/gui/main.go @@ -1,122 +1,16 @@ package main import ( - "errors" "fmt" "os" - "runtime" - "github.com/disintegration/imaging" + "github.com/struffel/simple-deflicker/internal/ui" ) func main() { - - //Initialize Window from config and start GUI - guiError := startGUI() - if guiError != nil { - fmt.Println(guiError) + if err := ui.StartGUI(); err != nil { + fmt.Println(err) os.Exit(1) } os.Exit(0) } - -func shouldRunCli(config configuration) bool { - return config.sourceDirectory != "" || config.destinationDirectory != "" -} - -func runDeflickering() error { - - //Prepare - configError := validateConfigInformation() - if configError != nil { - return configError - } - clear() - runtime.GOMAXPROCS(config.threads) - pictures, picturesError := readDirectory(config.sourceDirectory, config.destinationDirectory) - if picturesError != nil { - return picturesError - } - progress := createProgressBars(len(pictures)) - progress.container.Start() - - //Analyze and create Histograms - var analyzeError error - pictures, analyzeError = forEveryPicture(pictures, progress.bars["analyze"], config.threads, func(pic picture) (picture, error) { - img, err := imaging.Open(pic.currentPath) - if err != nil { - return pic, errors.New(pic.currentPath + " | " + err.Error()) - } - pic.currentRgbHistogram = generateRgbHistogramFromImage(img) - return pic, nil - }) - if analyzeError != nil { - progress.container.Stop() - return analyzeError - } - - //Calculate global or rolling average - if config.rollingAverage < 1 { - var averageRgbHistogram rgbHistogram - for i := range pictures { - for j := 0; j < 256; j++ { - averageRgbHistogram.r[j] += pictures[i].currentRgbHistogram.r[j] - averageRgbHistogram.g[j] += pictures[i].currentRgbHistogram.g[j] - averageRgbHistogram.b[j] += pictures[i].currentRgbHistogram.b[j] - } - } - for i := 0; i < 256; i++ { - averageRgbHistogram.r[i] /= uint32(len(pictures)) - averageRgbHistogram.g[i] /= uint32(len(pictures)) - averageRgbHistogram.b[i] /= uint32(len(pictures)) - } - for i := range pictures { - pictures[i].targetRgbHistogram = averageRgbHistogram - } - } else { - for i := range pictures { - var averageRgbHistogram rgbHistogram - var start = i - config.rollingAverage - if start < 0 { - start = 0 - } - var end = i + config.rollingAverage - if end > len(pictures)-1 { - end = len(pictures) - 1 - } - for i := start; i <= end; i++ { - for j := 0; j < 256; j++ { - averageRgbHistogram.r[j] += pictures[i].currentRgbHistogram.r[j] - averageRgbHistogram.g[j] += pictures[i].currentRgbHistogram.g[j] - averageRgbHistogram.b[j] += pictures[i].currentRgbHistogram.b[j] - } - } - for i := 0; i < 256; i++ { - averageRgbHistogram.r[i] /= uint32(end - start + 1) - averageRgbHistogram.g[i] /= uint32(end - start + 1) - averageRgbHistogram.b[i] /= uint32(end - start + 1) - } - pictures[i].targetRgbHistogram = averageRgbHistogram - } - } - - var adjustError error - pictures, adjustError = forEveryPicture(pictures, progress.bars["adjust"], config.threads, func(pic picture) (picture, error) { - var img, _ = imaging.Open(pic.currentPath) - lut := generateRgbLutFromRgbHistograms(pic.currentRgbHistogram, pic.targetRgbHistogram) - img = applyRgbLutToImage(img, lut) - err := imaging.Save(img, pic.targetPath, imaging.JPEGQuality(config.jpegCompression), imaging.PNGCompressionLevel(0)) - if err != nil { - return pic, errors.New(pic.currentPath + " | " + err.Error()) - } - return pic, nil - }) - if adjustError != nil { - progress.container.Stop() - return adjustError - } - progress.container.Stop() - clear() - fmt.Printf("Saved %v pictures into %v", len(pictures), config.destinationDirectory) - return nil -} diff --git a/gui_cli.go b/gui_cli.go deleted file mode 100644 index 63ab161..0000000 --- a/gui_cli.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build cli -// +build cli - -package main - -import "errors" - -func startGUI() error { - return errors.New("GUI mode is not available in this build. Use -source and -destination to run in CLI mode") -} diff --git a/internal/deflicker/deflicker.go b/internal/deflicker/deflicker.go index b248d9d..cc3549f 100644 --- a/internal/deflicker/deflicker.go +++ b/internal/deflicker/deflicker.go @@ -80,6 +80,7 @@ func AdjustImages(pictures *[]PictureInfo, settings Settings, updater progress.U func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, rollingAverage int) error { if rollingAverage < 1 { + // Simply calculate the global average histogram var averageRgbHistogram RgbHistogram for i := range *pictures { for j := 0; j < 256; j++ { @@ -98,6 +99,7 @@ func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, ro (*pictures)[i].DesiredRgbHistogram = averageRgbHistogram } } else { + // Calculate the rolling average histogram for each image for i := range *pictures { var averageRgbHistogram RgbHistogram var start = i - rollingAverage @@ -136,7 +138,7 @@ func GetSourcePictureInfo(directory string, updater progress.Updater) ([]Picture return nil, err } - // Filter down to the compatible image file names, preserving directory order + // Filter down to the compatible image file names var imageNames []string for _, file := range files { extension := strings.ToLower(filepath.Ext(file.Name())) @@ -155,7 +157,7 @@ func GetSourcePictureInfo(directory string, updater progress.Updater) ([]Picture var g errgroup.Group g.SetLimit(runtime.NumCPU()) - // Calculate histograms concurrently, writing results into their original index to preserve order + // Calculate histograms concurrently for index, name := range imageNames { g.Go(func() error { image, err := ReadImage(filepath.Join(directory, name)) diff --git a/internal/progress/zenity.go b/internal/progress/zenity.go new file mode 100644 index 0000000..c85253c --- /dev/null +++ b/internal/progress/zenity.go @@ -0,0 +1,50 @@ +package progress + +import ( + "fmt" + + "github.com/ncruces/zenity" +) + +// ZenityUpdater is an implementation of the Updater interface that displays +// progress using a native zenity progress dialog. +type ZenityUpdater struct { + title string + dlg zenity.ProgressDialog +} + +// NewZenityUpdater creates a ZenityUpdater that will show a progress dialog +// with the given title once Start is called. +func NewZenityUpdater(title string) *ZenityUpdater { + return &ZenityUpdater{title: title} +} + +func (z *ZenityUpdater) Start() { + dlg, err := zenity.Progress( + zenity.Title(z.title), + zenity.MaxValue(100), + zenity.NoCancel(), + ) + if err != nil { + return + } + z.dlg = dlg +} + +func (z *ZenityUpdater) Increment(msg string, phase string, completed int, ofTotal int) { + if z.dlg == nil { + return + } + if ofTotal > 0 { + z.dlg.Value(completed * 100 / ofTotal) + } + z.dlg.Text(fmt.Sprintf("%s: %s (%d/%d)", phase, msg, completed, ofTotal)) +} + +func (z *ZenityUpdater) Finish() { + if z.dlg == nil { + return + } + z.dlg.Complete() + z.dlg.Close() +} diff --git a/internal/ui/gui.go b/internal/ui/gui.go index 17fa736..19393a9 100644 --- a/internal/ui/gui.go +++ b/internal/ui/gui.go @@ -1,6 +1,3 @@ -//go:build !cli -// +build !cli - package ui import ( @@ -13,13 +10,17 @@ import ( "gioui.org/layout" "gioui.org/op" "gioui.org/unit" - "gioui.org/widget" "gioui.org/widget/material" "github.com/ncruces/zenity" + + "github.com/struffel/simple-deflicker/internal/deflicker" + "github.com/struffel/simple-deflicker/internal/progress" ) -func startGUI() error { +// StartGUI launches the graphical user interface and blocks until the +// window is closed. +func StartGUI() error { go func() { w := new(app.Window) w.Option(app.Title("Simple Deflicker"), app.Size(unit.Dp(480), unit.Dp(420))) @@ -35,7 +36,7 @@ func startGUI() error { func runWindow(w *app.Window) error { theme := material.NewTheme() - state := newGuiState() + state := NewUiState(DefaultSettings()) var ops op.Ops for { @@ -55,7 +56,7 @@ func runWindow(w *app.Window) error { // receiveGuiResults applies any results delivered by background goroutines // (directory pickers, deflickering) to the widget state. It must only be // called from the goroutine that owns the window. -func receiveGuiResults(state *guiState) { +func receiveGuiResults(state *UiState) { select { case dir := <-state.sourceResult: state.sourceEditor.SetText(filepath.ToSlash(dir)) @@ -73,14 +74,17 @@ func receiveGuiResults(state *guiState) { state.statusText = "An error occurred: " + err.Error() go zenity.Error(err.Error(), zenity.Title("Simple Deflicker - Error")) } else { - state.statusText = "Saved pictures into " + config.destinationDirectory + state.statusText = "Saved pictures into " + state.Settings.DestinationDirectory go zenity.Info(state.statusText, zenity.Title("Simple Deflicker")) } default: } } -func handleGuiEvents(gtx layout.Context, w *app.Window, state *guiState) { +func handleGuiEvents(gtx layout.Context, w *app.Window, state *UiState) { + state.formatEnum.Update(gtx) + state.Settings.OutFormat = deflicker.OutputFormat(state.formatEnum.Value) + if state.browseSourceBtn.Clicked(gtx) { go func() { dir, err := zenity.SelectFile(zenity.Directory(), zenity.Title("Select a source directory.")) @@ -104,103 +108,37 @@ func handleGuiEvents(gtx layout.Context, w *app.Window, state *guiState) { } } -// startDeflickering reads the current widget values into config, then runs -// the deflickering process in the background while a zenity progress dialog -// gives the user feedback. -func startDeflickering(w *app.Window, state *guiState) { - config.sourceDirectory = filepath.ToSlash(state.sourceEditor.Text()) - config.destinationDirectory = filepath.ToSlash(state.destinationEditor.Text()) +// startDeflickering reads the current widget values into the settings, then +// runs the deflickering process in the background while a zenity progress +// dialog gives the user feedback. +func startDeflickering(w *app.Window, state *UiState) { + state.Settings.SourceDirectory = filepath.ToSlash(state.sourceEditor.Text()) + state.Settings.DestinationDirectory = filepath.ToSlash(state.destinationEditor.Text()) if v, err := strconv.Atoi(state.rollingAvgEditor.Text()); err == nil { - config.rollingAverage = v + state.Settings.RollingAverage = v } if v, err := strconv.Atoi(state.jpegQualityEditor.Text()); err == nil { - config.jpegCompression = v + state.Settings.JpegQuality = v } - if v, err := strconv.Atoi(state.threadsEditor.Text()); err == nil { - config.threads = v + + if validationErrors := state.Settings.Validate(); len(validationErrors) > 0 { + msg := "" + for _, validationError := range validationErrors { + msg += validationError.Error() + "\n" + } + go zenity.Error(msg, zenity.Title("Simple Deflicker - Invalid settings")) + return } state.processing = true state.statusText = "Processing..." + settings := state.Settings go func() { - dlg, dlgErr := zenity.Progress( - zenity.Title("Simple Deflicker"), - zenity.Pulsate(), - zenity.NoCancel(), - ) - if dlgErr == nil { - dlg.Text("Processing images, this can take a while...") - } - - err := runDeflickering() - - if dlgErr == nil { - dlg.Complete() - dlg.Close() - } - + updater := progress.NewZenityUpdater("Simple Deflicker") + err := deflicker.Run(settings, updater) + updater.Finish() state.deflickerResult <- err w.Invalidate() }() } - -func layoutGui(gtx layout.Context, th *material.Theme, state *guiState) layout.Dimensions { - return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(material.Body1(th, "Source Directory").Layout), - layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor)), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(material.Button(th, &state.browseSourceBtn, "Browse").Layout), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - - layout.Rigid(material.Body1(th, "Destination Directory").Layout), - layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor)), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(material.Button(th, &state.browseDestinationBtn, "Browse").Layout), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), - - layout.Rigid(material.Body1(th, "Advanced settings").Layout), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor)), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Flexed(1, labeledEditor(th, "JPEG quality", &state.jpegQualityEditor)), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Flexed(1, labeledEditor(th, "Threads", &state.threadsEditor)), - ) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), - - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - text := "Start" - if state.processing { - text = "Processing..." - } - return material.Button(th, &state.startBtn, text).Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(material.Body2(th, state.statusText).Layout), - ) - }) -} - -func labeledEditor(th *material.Theme, label string, ed *widget.Editor) layout.Widget { - return func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(material.Caption(th, label).Layout), - layout.Rigid(fullWidthBorderedEditor(th, ed)), - ) - } -} - -func fullWidthBorderedEditor(th *material.Theme, ed *widget.Editor) layout.Widget { - return func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - border := widget.Border{Color: th.Fg, Width: unit.Dp(1), CornerRadius: unit.Dp(4)} - return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.UniformInset(unit.Dp(6)).Layout(gtx, material.Editor(th, ed, "").Layout) - }) - } -} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..293214d --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,104 @@ +package ui + +import ( + "image/color" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/struffel/simple-deflicker/internal/deflicker" +) + +func layoutGui(gtx layout.Context, th *material.Theme, state *UiState) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(th, "Source Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Button(th, &state.browseSourceBtn, "Browse").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + layout.Rigid(material.Body1(th, "Destination Directory").Layout), + layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Button(th, &state.browseDestinationBtn, "Browse").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + + layout.Rigid(material.Body1(th, "Advanced settings").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + jpegQualityDisabled := state.Settings.OutFormat != deflicker.FormatJpeg + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor, false)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, labeledEditor(th, "JPEG quality", &state.jpegQualityEditor, jpegQualityDisabled)), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Flexed(1, formatSelector(th, state)), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + text := "Start" + if state.processing { + text = "Processing..." + } + return material.Button(th, &state.startBtn, text).Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body2(th, state.statusText).Layout), + ) + }) +} + +func formatSelector(th *material.Theme, state *UiState) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Caption(th, "Output format").Layout), + layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatPng), "PNG").Layout), + layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatJpeg), "JPEG").Layout), + ) + } +} + +func labeledEditor(th *material.Theme, label string, ed *widget.Editor, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + caption := material.Caption(th, label) + if disabled { + caption.Color = disabledColor(th) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(caption.Layout), + layout.Rigid(fullWidthBorderedEditor(th, ed, disabled)), + ) + } +} + +// disabledColor returns the theme foreground color at half opacity, used to +// visually gray out disabled controls. +func disabledColor(th *material.Theme) color.NRGBA { + c := th.Fg + c.A = c.A / 2 + return c +} + +func fullWidthBorderedEditor(th *material.Theme, ed *widget.Editor, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + borderColor := th.Fg + if disabled { + gtx = gtx.Disabled() + borderColor = disabledColor(th) + } + border := widget.Border{Color: borderColor, Width: unit.Dp(1), CornerRadius: unit.Dp(4)} + return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + editorStyle := material.Editor(th, ed, "") + if disabled { + editorStyle.Color = disabledColor(th) + editorStyle.HintColor = disabledColor(th) + } + return layout.UniformInset(unit.Dp(6)).Layout(gtx, editorStyle.Layout) + }) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f3bb778..dfbb957 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -4,11 +4,11 @@ import ( "strconv" "gioui.org/widget" - "github.com/struffel/simple-deflicker/internal" + "github.com/struffel/simple-deflicker/internal/deflicker" ) type UiState struct { - Settings internal.Settings + Settings deflicker.Settings sourceResult chan string destinationResult chan string @@ -21,15 +21,16 @@ type UiState struct { destinationEditor widget.Editor rollingAvgEditor widget.Editor jpegQualityEditor widget.Editor - threadsEditor widget.Editor + formatEnum widget.Enum browseSourceBtn widget.Clickable browseDestinationBtn widget.Clickable startBtn widget.Clickable } -func NewUiState(settings internal.Settings) *UiState { +func NewUiState(settings deflicker.Settings) *UiState { state := &UiState{ + Settings: settings, sourceResult: make(chan string, 1), destinationResult: make(chan string, 1), deflickerResult: make(chan error, 1), @@ -48,10 +49,17 @@ func NewUiState(settings internal.Settings) *UiState { state.jpegQualityEditor.SingleLine = true state.jpegQualityEditor.Filter = "0123456789" - state.jpegQualityEditor.SetText(strconv.Itoa(settings.JpegCompression)) + state.jpegQualityEditor.SetText(strconv.Itoa(settings.JpegQuality)) - state.threadsEditor.SingleLine = true - state.threadsEditor.Filter = "0123456789" - state.threadsEditor.SetText(strconv.Itoa(settings.Threads)) + state.formatEnum.Value = string(settings.OutFormat) return state } + +// DefaultSettings returns the settings the GUI is pre-populated with. +func DefaultSettings() deflicker.Settings { + return deflicker.Settings{ + RollingAverage: 15, + OutFormat: deflicker.FormatPng, + JpegQuality: 95, + } +} From 507433c8ba545a8fbf6dd0ae93cc8e729adcfa6c Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:56:27 +0200 Subject: [PATCH 04/15] Create launch.json --- .vscode/launch.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6c3d4f7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch GUI", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "./cmd/gui" + } + ] +} \ No newline at end of file From 73e39759c0044db133d8fc91290f0ec4ea85bcc5 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:56:59 +0200 Subject: [PATCH 05/15] Move progress into GUI --- internal/deflicker/deflicker.go | 43 +++++++++++---------- internal/deflicker/histogram.go | 66 ++++++++++++++++----------------- internal/deflicker/io.go | 8 ++-- internal/deflicker/settings.go | 31 ++++++++++++---- internal/progress/progress.go | 9 +---- internal/progress/zenity.go | 50 ------------------------- internal/ui/gui.go | 44 +++++++++++++++++----- internal/ui/layout.go | 29 +++++++++++++-- internal/ui/{ui.go => state.go} | 33 ++++++++++++----- 9 files changed, 169 insertions(+), 144 deletions(-) delete mode 100644 internal/progress/zenity.go rename internal/ui/{ui.go => state.go} (65%) diff --git a/internal/deflicker/deflicker.go b/internal/deflicker/deflicker.go index cc3549f..bf101d5 100644 --- a/internal/deflicker/deflicker.go +++ b/internal/deflicker/deflicker.go @@ -13,10 +13,10 @@ import ( "golang.org/x/sync/errgroup" ) -type PictureInfo struct { +type pictureInfo struct { Name string - OriginalRgbHistogram RgbHistogram - DesiredRgbHistogram RgbHistogram + OriginalRgbHistogram rgbHistogram + DesiredRgbHistogram rgbHistogram } func Run(settings Settings, updater progress.Updater) error { @@ -30,23 +30,23 @@ func Run(settings Settings, updater progress.Updater) error { updater.Start() // Read the entire source directory and create Picture structs with histograms - pictures, err := GetSourcePictureInfo(settings.SourceDirectory, updater) + pictures, err := getSourcePictureInfo(settings.SourceDirectory, updater) if err != nil { return err } - err = FillDesiredHistograms(&pictures, updater, settings.RollingAverage) + err = fillDesiredHistograms(&pictures, updater, settings.RollingAverage) if err != nil { return err } - AdjustImages(&pictures, settings, updater) + adjustImages(&pictures, settings, updater) updater.Finish() return nil } -func AdjustImages(pictures *[]PictureInfo, settings Settings, updater progress.Updater) error { +func adjustImages(pictures *[]pictureInfo, settings Settings, updater progress.Updater) error { total := len(*pictures) var completed atomic.Int32 @@ -56,17 +56,20 @@ func AdjustImages(pictures *[]PictureInfo, settings Settings, updater progress.U for i := range *pictures { g.Go(func() error { sourcePath := filepath.Join(settings.SourceDirectory, (*pictures)[i].Name) - destinationPath := filepath.Join(settings.DestinationDirectory, (*pictures)[i].Name) - sourceImage, err := ReadImage(sourcePath) + destinationExtension := settings.OutFormat.Extension() + destinationFileName := strings.TrimSuffix((*pictures)[i].Name, filepath.Ext((*pictures)[i].Name)) + destinationExtension + destinationPath := filepath.Join(settings.DestinationDirectory, destinationFileName) + + sourceImage, err := readImage(sourcePath) if err != nil { return err } - destinationLut := GenerateRgbLutFromRgbHistograms((*pictures)[i].OriginalRgbHistogram, (*pictures)[i].DesiredRgbHistogram) - destinationImage := ApplyRgbLutToImage(sourceImage, destinationLut) + destinationLut := generateRgbLutFromRgbHistograms((*pictures)[i].OriginalRgbHistogram, (*pictures)[i].DesiredRgbHistogram) + destinationImage := applyRgbLutToImage(sourceImage, destinationLut) - if err := SaveImage(destinationImage, destinationPath, settings.OutFormat, settings.JpegQuality); err != nil { + if err := saveImage(destinationImage, destinationPath, settings.OutFormat, settings.JpegQuality); err != nil { return err } @@ -77,11 +80,11 @@ func AdjustImages(pictures *[]PictureInfo, settings Settings, updater progress.U return g.Wait() } -func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, rollingAverage int) error { +func fillDesiredHistograms(pictures *[]pictureInfo, updater progress.Updater, rollingAverage int) error { if rollingAverage < 1 { // Simply calculate the global average histogram - var averageRgbHistogram RgbHistogram + var averageRgbHistogram rgbHistogram for i := range *pictures { for j := 0; j < 256; j++ { averageRgbHistogram.R[j] += (*pictures)[i].OriginalRgbHistogram.R[j] @@ -101,7 +104,7 @@ func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, ro } else { // Calculate the rolling average histogram for each image for i := range *pictures { - var averageRgbHistogram RgbHistogram + var averageRgbHistogram rgbHistogram var start = i - rollingAverage if start < 0 { start = 0 @@ -130,7 +133,7 @@ func FillDesiredHistograms(pictures *[]PictureInfo, updater progress.Updater, ro return nil } -func GetSourcePictureInfo(directory string, updater progress.Updater) ([]PictureInfo, error) { +func getSourcePictureInfo(directory string, updater progress.Updater) ([]pictureInfo, error) { // Get raw list of files files, err := os.ReadDir(directory) @@ -151,7 +154,7 @@ func GetSourcePictureInfo(directory string, updater progress.Updater) ([]Picture } totalFiles := len(imageNames) - pictures := make([]PictureInfo, totalFiles) + pictures := make([]pictureInfo, totalFiles) var completed atomic.Int32 var g errgroup.Group @@ -160,12 +163,12 @@ func GetSourcePictureInfo(directory string, updater progress.Updater) ([]Picture // Calculate histograms concurrently for index, name := range imageNames { g.Go(func() error { - image, err := ReadImage(filepath.Join(directory, name)) + image, err := readImage(filepath.Join(directory, name)) if err != nil { return err } - histogram := GenerateRgbHistogramFromImage(image) - pictures[index] = PictureInfo{Name: name, OriginalRgbHistogram: histogram, DesiredRgbHistogram: RgbHistogram{}} + histogram := generateRgbHistogramFromImage(image) + pictures[index] = pictureInfo{Name: name, OriginalRgbHistogram: histogram, DesiredRgbHistogram: rgbHistogram{}} updater.Increment(name, "Calculating histogram", int(completed.Add(1)), totalFiles) return nil diff --git a/internal/deflicker/histogram.go b/internal/deflicker/histogram.go index 5c8aa6d..dc07fa7 100644 --- a/internal/deflicker/histogram.go +++ b/internal/deflicker/histogram.go @@ -8,17 +8,17 @@ import ( "github.com/disintegration/imaging" ) -type Lut [256]uint8 -type RgbLut struct { - R Lut - G Lut - B Lut +type lut [256]uint8 +type rgbLut struct { + R lut + G lut + B lut } -type Histogram [256]uint32 -type RgbHistogram struct { - R Histogram - G Histogram - B Histogram +type histogram [256]uint32 +type rgbHistogram struct { + R histogram + G histogram + B histogram } const ( @@ -26,9 +26,9 @@ const ( lutSmoothingRadius = 5 ) -// GenerateRgbHistogramFromImage generates an RGB histogram from the given image. -func GenerateRgbHistogramFromImage(input image.Image) RgbHistogram { - var rgbHistogram RgbHistogram +// generateRgbHistogramFromImage generates an RGB histogram from the given image. +func generateRgbHistogramFromImage(input image.Image) rgbHistogram { + var rgbHistogram rgbHistogram for y := input.Bounds().Min.Y; y < input.Bounds().Max.Y; y++ { for x := input.Bounds().Min.X; x < input.Bounds().Max.X; x++ { r, g, b, _ := input.At(x, y).RGBA() @@ -43,9 +43,9 @@ func GenerateRgbHistogramFromImage(input image.Image) RgbHistogram { return rgbHistogram } -// ConvertToCumulativeRgbHistogram converts a given RGB histogram into a cumulative histogram. -func ConvertToCumulativeRgbHistogram(input RgbHistogram) RgbHistogram { - var targetRgbHistogram RgbHistogram +// convertToCumulativeRgbHistogram converts a given RGB histogram into a cumulative histogram. +func convertToCumulativeRgbHistogram(input rgbHistogram) rgbHistogram { + var targetRgbHistogram rgbHistogram targetRgbHistogram.R[0] = input.R[0] targetRgbHistogram.G[0] = input.G[0] targetRgbHistogram.B[0] = input.B[0] @@ -57,11 +57,11 @@ func ConvertToCumulativeRgbHistogram(input RgbHistogram) RgbHistogram { return targetRgbHistogram } -// GenerateRgbLutFromRgbHistograms generates a lookup table (LUT) for each color channel (R, G, B) based on the current and target RGB histograms. +// generateRgbLutFromRgbHistograms generates a lookup table (LUT) for each color channel (R, G, B) based on the current and target RGB histograms. // The LUT is used to map pixel values from the current image to the target image, effectively adjusting the colors to match the desired histogram. -func GenerateRgbLutFromRgbHistograms(current RgbHistogram, target RgbHistogram) RgbLut { - currentCumulativeRgbHistogram := ConvertToCumulativeRgbHistogram(current) - targetCumulativeRgbHistogram := ConvertToCumulativeRgbHistogram(target) +func generateRgbLutFromRgbHistograms(current rgbHistogram, target rgbHistogram) rgbLut { + currentCumulativeRgbHistogram := convertToCumulativeRgbHistogram(current) + targetCumulativeRgbHistogram := convertToCumulativeRgbHistogram(target) var ratio [3]float64 ratio[0] = float64(currentCumulativeRgbHistogram.R[255]) / float64(targetCumulativeRgbHistogram.R[255]) @@ -74,7 +74,7 @@ func GenerateRgbLutFromRgbHistograms(current RgbHistogram, target RgbHistogram) } //Generate LUT - var lut RgbLut + var lut rgbLut var p [3]uint8 for i := 0; i < 256; i++ { for targetCumulativeRgbHistogram.R[p[0]] < currentCumulativeRgbHistogram.R[i] { @@ -90,14 +90,14 @@ func GenerateRgbLutFromRgbHistograms(current RgbHistogram, target RgbHistogram) lut.G[i] = p[1] lut.B[i] = p[2] } - lut.R = RegularizeLut(lut.R) - lut.G = RegularizeLut(lut.G) - lut.B = RegularizeLut(lut.B) + lut.R = regularizeLut(lut.R) + lut.G = regularizeLut(lut.G) + lut.B = regularizeLut(lut.B) return lut } -// ApplyRgbLutToImage applies the given RGB lookup table (LUT) to the input image, adjusting its pixel values according to the LUT. -func ApplyRgbLutToImage(input image.Image, lut RgbLut) image.Image { +// applyRgbLutToImage applies the given RGB lookup table (LUT) to the input image, adjusting its pixel values according to the LUT. +func applyRgbLutToImage(input image.Image, lut rgbLut) image.Image { result := imaging.AdjustFunc(input, func(c color.NRGBA) color.NRGBA { c.R = uint8(lut.R[c.R]) c.G = uint8(lut.G[c.G]) @@ -107,10 +107,10 @@ func ApplyRgbLutToImage(input image.Image, lut RgbLut) image.Image { return result } -// RegularizeLut smooths and corrects the given lookup table (LUT) to ensure that it is monotonically increasing and applies a correction strength to the values. +// regularizeLut smooths and corrects the given lookup table (LUT) to ensure that it is monotonically increasing and applies a correction strength to the values. // This helps in preventing abrupt changes in pixel values when applying the LUT to an image. -func RegularizeLut(input Lut) Lut { - var smoothed Lut +func regularizeLut(input lut) lut { + var smoothed lut for i := 0; i < 256; i++ { sum := 0 count := 0 @@ -129,16 +129,16 @@ func RegularizeLut(input Lut) Lut { } } - var result Lut + var result lut for i := 0; i < 256; i++ { corrected := float64(smoothed[i])*lutCorrectionStrength + float64(i)*(1-lutCorrectionStrength) - result[i] = ClampUint8(corrected) + result[i] = clampUint8(corrected) } return result } -// ClampUint8 clamps a float64 value to the range of 0 to 255 and returns it as a uint8. -func ClampUint8(value float64) uint8 { +// clampUint8 clamps a float64 value to the range of 0 to 255 and returns it as a uint8. +func clampUint8(value float64) uint8 { value = math.Round(value) if value < 0 { return 0 diff --git a/internal/deflicker/io.go b/internal/deflicker/io.go index 7bf8d55..4736c51 100644 --- a/internal/deflicker/io.go +++ b/internal/deflicker/io.go @@ -10,7 +10,7 @@ import ( "strings" ) -func DirectoryExists(path string) bool { +func directoryExists(path string) bool { stat, err := os.Stat(path) if err == nil && stat.IsDir() { return true @@ -18,7 +18,7 @@ func DirectoryExists(path string) bool { return false } -func ListImagesInDirectory(path string) ([]string, error) { +func listImagesInDirectory(path string) ([]string, error) { files, err := os.ReadDir(path) if err != nil { return nil, err @@ -33,7 +33,7 @@ func ListImagesInDirectory(path string) ([]string, error) { return images, nil } -func ReadImage(path string) (image.Image, error) { +func readImage(path string) (image.Image, error) { file, err := os.Open(path) if err != nil { return nil, err @@ -47,7 +47,7 @@ func ReadImage(path string) (image.Image, error) { return img, nil } -func SaveImage(img image.Image, path string, format OutputFormat, jpegQuality int) error { +func saveImage(img image.Image, path string, format OutputFormat, jpegQuality int) error { file, err := os.Create(path) if err != nil { return err diff --git a/internal/deflicker/settings.go b/internal/deflicker/settings.go index 1e771bb..b8c4a84 100644 --- a/internal/deflicker/settings.go +++ b/internal/deflicker/settings.go @@ -12,6 +12,17 @@ const ( FormatPng OutputFormat = "png" ) +func (f OutputFormat) Extension() string { + switch f { + case FormatJpeg: + return ".jpg" + case FormatPng: + return ".png" + default: + panic("Unknown output format") + } +} + type Settings struct { SourceDirectory string DestinationDirectory string @@ -20,18 +31,24 @@ type Settings struct { JpegQuality int } -func (s *Settings) UseGlobalAverage() bool { - return s.RollingAverage < 1 +// DefaultSettings returns the settings the GUI is pre-populated and that the CLI uses if no arguments are provided. +func DefaultSettings() Settings { + return Settings{ + RollingAverage: 15, + OutFormat: FormatPng, + JpegQuality: 95, + } } func NewSettingsFromArgs() Settings { + var defaultSettings = DefaultSettings() var settings Settings var tmpFormat string flag.StringVar(&settings.SourceDirectory, "source", "", "Directory with the images to process.") flag.StringVar(&settings.DestinationDirectory, "destination", "", "Directory to put the processed images in.") - flag.IntVar(&settings.RollingAverage, "rollingAverage", 15, "Number of frames to use for rolling average. 0 disables it.") - flag.StringVar(&tmpFormat, "format", "png", "Output format. Options are jpeg png.") - flag.IntVar(&settings.JpegQuality, "jpegQuality", 95, "Level of JPEG compression. Must be between 1 - 100.") + flag.IntVar(&settings.RollingAverage, "rollingAverage", defaultSettings.RollingAverage, "Number of frames to use for rolling average. 0 disables it.") + flag.StringVar(&tmpFormat, "format", string(defaultSettings.OutFormat), "Output format. Options are jpeg png.") + flag.IntVar(&settings.JpegQuality, "jpegQuality", defaultSettings.JpegQuality, "Level of JPEG compression. Must be between 1 - 100.") flag.Parse() settings.OutFormat = OutputFormat(tmpFormat) return settings @@ -52,12 +69,12 @@ func (s *Settings) Validate() []error { if s.SourceDirectory == "" { errors = append(errors, fmt.Errorf("No source directory specified.")) - } else if !DirectoryExists(s.SourceDirectory) { + } else if !directoryExists(s.SourceDirectory) { errors = append(errors, fmt.Errorf("The source directory could not be found.")) } if s.DestinationDirectory == "" { errors = append(errors, fmt.Errorf("No destination directory specified.")) - } else if !DirectoryExists(s.DestinationDirectory) { + } else if !directoryExists(s.DestinationDirectory) { errors = append(errors, fmt.Errorf("The destination directory could not be found.")) } return errors diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 24ef694..898a2cf 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -8,13 +8,6 @@ type Updater interface { Finish() } -// NullUpdater is a dummy implementation of the Updater interface that does nothing. -type NullUpdater struct{} - -func (n *NullUpdater) Start() {} -func (n *NullUpdater) Increment(msg string, phase string, completed int, ofTotal int) {} -func (n *NullUpdater) Finish() {} - // ConsoleUpdater is an implementation of the Updater interface that prints progress to the console. type ConsoleUpdater struct{} @@ -23,7 +16,7 @@ func (c *ConsoleUpdater) Start() { } func (c *ConsoleUpdater) Increment(msg string, phase string, completed int, ofTotal int) { - fmt.Printf("[%s] %s (%d/%d)\n", phase, msg, completed, ofTotal) + fmt.Printf("%s: %s (%d/%d)\n", phase, msg, completed, ofTotal) } func (c *ConsoleUpdater) Finish() { diff --git a/internal/progress/zenity.go b/internal/progress/zenity.go deleted file mode 100644 index c85253c..0000000 --- a/internal/progress/zenity.go +++ /dev/null @@ -1,50 +0,0 @@ -package progress - -import ( - "fmt" - - "github.com/ncruces/zenity" -) - -// ZenityUpdater is an implementation of the Updater interface that displays -// progress using a native zenity progress dialog. -type ZenityUpdater struct { - title string - dlg zenity.ProgressDialog -} - -// NewZenityUpdater creates a ZenityUpdater that will show a progress dialog -// with the given title once Start is called. -func NewZenityUpdater(title string) *ZenityUpdater { - return &ZenityUpdater{title: title} -} - -func (z *ZenityUpdater) Start() { - dlg, err := zenity.Progress( - zenity.Title(z.title), - zenity.MaxValue(100), - zenity.NoCancel(), - ) - if err != nil { - return - } - z.dlg = dlg -} - -func (z *ZenityUpdater) Increment(msg string, phase string, completed int, ofTotal int) { - if z.dlg == nil { - return - } - if ofTotal > 0 { - z.dlg.Value(completed * 100 / ofTotal) - } - z.dlg.Text(fmt.Sprintf("%s: %s (%d/%d)", phase, msg, completed, ofTotal)) -} - -func (z *ZenityUpdater) Finish() { - if z.dlg == nil { - return - } - z.dlg.Complete() - z.dlg.Close() -} diff --git a/internal/ui/gui.go b/internal/ui/gui.go index 19393a9..2ed4fe1 100644 --- a/internal/ui/gui.go +++ b/internal/ui/gui.go @@ -15,7 +15,6 @@ import ( "github.com/ncruces/zenity" "github.com/struffel/simple-deflicker/internal/deflicker" - "github.com/struffel/simple-deflicker/internal/progress" ) // StartGUI launches the graphical user interface and blocks until the @@ -36,7 +35,7 @@ func StartGUI() error { func runWindow(w *app.Window) error { theme := material.NewTheme() - state := NewUiState(DefaultSettings()) + state := newUiState(deflicker.DefaultSettings()) var ops op.Ops for { @@ -56,7 +55,7 @@ func runWindow(w *app.Window) error { // receiveGuiResults applies any results delivered by background goroutines // (directory pickers, deflickering) to the widget state. It must only be // called from the goroutine that owns the window. -func receiveGuiResults(state *UiState) { +func receiveGuiResults(state *uiState) { select { case dir := <-state.sourceResult: state.sourceEditor.SetText(filepath.ToSlash(dir)) @@ -70,6 +69,7 @@ func receiveGuiResults(state *UiState) { select { case err := <-state.deflickerResult: state.processing = false + state.setProgress(0, "") if err != nil { state.statusText = "An error occurred: " + err.Error() go zenity.Error(err.Error(), zenity.Title("Simple Deflicker - Error")) @@ -81,7 +81,7 @@ func receiveGuiResults(state *UiState) { } } -func handleGuiEvents(gtx layout.Context, w *app.Window, state *UiState) { +func handleGuiEvents(gtx layout.Context, w *app.Window, state *uiState) { state.formatEnum.Update(gtx) state.Settings.OutFormat = deflicker.OutputFormat(state.formatEnum.Value) @@ -109,9 +109,9 @@ func handleGuiEvents(gtx layout.Context, w *app.Window, state *UiState) { } // startDeflickering reads the current widget values into the settings, then -// runs the deflickering process in the background while a zenity progress -// dialog gives the user feedback. -func startDeflickering(w *app.Window, state *UiState) { +// runs the deflickering process in the background while the native progress +// bar and start button give the user feedback. +func startDeflickering(w *app.Window, state *uiState) { state.Settings.SourceDirectory = filepath.ToSlash(state.sourceEditor.Text()) state.Settings.DestinationDirectory = filepath.ToSlash(state.destinationEditor.Text()) if v, err := strconv.Atoi(state.rollingAvgEditor.Text()); err == nil { @@ -135,10 +135,36 @@ func startDeflickering(w *app.Window, state *UiState) { settings := state.Settings go func() { - updater := progress.NewZenityUpdater("Simple Deflicker") + updater := &guiUpdater{state: state, win: w} err := deflicker.Run(settings, updater) - updater.Finish() state.deflickerResult <- err w.Invalidate() }() } + +// guiUpdater implements progress.Updater by writing progress into the UiState +// and invalidating the window so the native progress bar redraws. It is safe +// for use from background goroutines. +type guiUpdater struct { + state *uiState + win *app.Window +} + +func (u *guiUpdater) Start() { + u.state.setProgress(0, "Starting...") + u.win.Invalidate() +} + +func (u *guiUpdater) Increment(msg string, phase string, completed int, ofTotal int) { + var fraction float32 + if ofTotal > 0 { + fraction = float32(completed) / float32(ofTotal) + } + u.state.setProgress(fraction, fmt.Sprintf("%s: %s (%d/%d)", phase, msg, completed, ofTotal)) + u.win.Invalidate() +} + +func (u *guiUpdater) Finish() { + u.state.setProgress(1, "Finishing...") + u.win.Invalidate() +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 293214d..e8e1236 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -10,21 +10,26 @@ import ( "github.com/struffel/simple-deflicker/internal/deflicker" ) -func layoutGui(gtx layout.Context, th *material.Theme, state *UiState) layout.Dimensions { +func layoutGui(gtx layout.Context, th *material.Theme, state *uiState) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + // Title + + // Source directory layout.Rigid(material.Body1(th, "Source Directory").Layout), layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Button(th, &state.browseSourceBtn, "Browse").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + // Destination directory layout.Rigid(material.Body1(th, "Destination Directory").Layout), layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(material.Button(th, &state.browseDestinationBtn, "Browse").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + // Advanced settings layout.Rigid(material.Body1(th, "Advanced settings").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -39,20 +44,38 @@ func layoutGui(gtx layout.Context, th *material.Theme, state *UiState) layout.Di }), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + // Start button and progress bar layout.Rigid(func(gtx layout.Context) layout.Dimensions { text := "Start" if state.processing { text = "Processing..." + if _, progressText := state.progress(); progressText != "" { + text = progressText + } + gtx = gtx.Disabled() } return material.Button(th, &state.startBtn, text).Layout(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(20)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + fraction, _ := state.progress() + bar := material.ProgressBar(th, fraction) + bar.Height = unit.Dp(8) + return bar.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(material.Body2(th, state.statusText).Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + displayText := state.statusText + if state.processing { + displayText = "" + } + return material.Body2(th, displayText).Layout(gtx) + }), ) }) } -func formatSelector(th *material.Theme, state *UiState) layout.Widget { +func formatSelector(th *material.Theme, state *uiState) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(material.Caption(th, "Output format").Layout), diff --git a/internal/ui/ui.go b/internal/ui/state.go similarity index 65% rename from internal/ui/ui.go rename to internal/ui/state.go index dfbb957..5da5f7d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/state.go @@ -2,12 +2,13 @@ package ui import ( "strconv" + "sync" "gioui.org/widget" "github.com/struffel/simple-deflicker/internal/deflicker" ) -type UiState struct { +type uiState struct { Settings deflicker.Settings sourceResult chan string @@ -17,6 +18,10 @@ type UiState struct { processing bool statusText string + progressMu sync.Mutex + progressFraction float32 + progressText string + sourceEditor widget.Editor destinationEditor widget.Editor rollingAvgEditor widget.Editor @@ -28,8 +33,8 @@ type UiState struct { startBtn widget.Clickable } -func NewUiState(settings deflicker.Settings) *UiState { - state := &UiState{ +func newUiState(settings deflicker.Settings) *uiState { + state := &uiState{ Settings: settings, sourceResult: make(chan string, 1), destinationResult: make(chan string, 1), @@ -55,11 +60,19 @@ func NewUiState(settings deflicker.Settings) *UiState { return state } -// DefaultSettings returns the settings the GUI is pre-populated with. -func DefaultSettings() deflicker.Settings { - return deflicker.Settings{ - RollingAverage: 15, - OutFormat: deflicker.FormatPng, - JpegQuality: 95, - } +// setProgress updates the current progress fraction (0..1) and status text. +// It is safe to call from any goroutine. +func (s *uiState) setProgress(fraction float32, text string) { + s.progressMu.Lock() + s.progressFraction = fraction + s.progressText = text + s.progressMu.Unlock() +} + +// progress returns the current progress fraction (0..1) and status text. It +// is safe to call from any goroutine. +func (s *uiState) progress() (float32, string) { + s.progressMu.Lock() + defer s.progressMu.Unlock() + return s.progressFraction, s.progressText } From 9540d3f65bce9cc7f8b062d817639c55af0b4a17 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:15:34 +0200 Subject: [PATCH 06/15] Disable GUI properly --- internal/progress/progress.go | 2 +- internal/ui/gui.go | 8 ++++-- internal/ui/layout.go | 48 ++++++++++++++++++++++------------- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 898a2cf..4c102d7 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -8,7 +8,7 @@ type Updater interface { Finish() } -// ConsoleUpdater is an implementation of the Updater interface that prints progress to the console. +// Default implementation for printing to the console. type ConsoleUpdater struct{} func (c *ConsoleUpdater) Start() { diff --git a/internal/ui/gui.go b/internal/ui/gui.go index 2ed4fe1..96fefe5 100644 --- a/internal/ui/gui.go +++ b/internal/ui/gui.go @@ -22,7 +22,7 @@ import ( func StartGUI() error { go func() { w := new(app.Window) - w.Option(app.Title("Simple Deflicker"), app.Size(unit.Dp(480), unit.Dp(420))) + w.Option(app.Title("Simple Deflicker"), app.Size(unit.Dp(400), unit.Dp(500))) if err := runWindow(w); err != nil { fmt.Println(err) os.Exit(1) @@ -82,6 +82,10 @@ func receiveGuiResults(state *uiState) { } func handleGuiEvents(gtx layout.Context, w *app.Window, state *uiState) { + if state.processing { + return + } + state.formatEnum.Update(gtx) state.Settings.OutFormat = deflicker.OutputFormat(state.formatEnum.Value) @@ -103,7 +107,7 @@ func handleGuiEvents(gtx layout.Context, w *app.Window, state *uiState) { } }() } - if state.startBtn.Clicked(gtx) && !state.processing { + if state.startBtn.Clicked(gtx) { startDeflickering(w, state) } } diff --git a/internal/ui/layout.go b/internal/ui/layout.go index e8e1236..e4e129d 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -10,36 +10,42 @@ import ( "github.com/struffel/simple-deflicker/internal/deflicker" ) +const version = "0.4.0" + func layoutGui(gtx layout.Context, th *material.Theme, state *uiState) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + controlsDisabled := state.processing return layout.Flex{Axis: layout.Vertical}.Layout(gtx, // Title + layout.Rigid(material.H6(th, "Simple Deflicker").Layout), + layout.Rigid(material.Caption(th, "Version "+version).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), // Source directory layout.Rigid(material.Body1(th, "Source Directory").Layout), - layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor, false)), + layout.Rigid(fullWidthBorderedEditor(th, &state.sourceEditor, controlsDisabled)), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(material.Button(th, &state.browseSourceBtn, "Browse").Layout), + layout.Rigid(buttonWithState(th, &state.browseSourceBtn, "Browse", controlsDisabled)), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), // Destination directory layout.Rigid(material.Body1(th, "Destination Directory").Layout), - layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor, false)), + layout.Rigid(fullWidthBorderedEditor(th, &state.destinationEditor, controlsDisabled)), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(material.Button(th, &state.browseDestinationBtn, "Browse").Layout), + layout.Rigid(buttonWithState(th, &state.browseDestinationBtn, "Browse", controlsDisabled)), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), // Advanced settings layout.Rigid(material.Body1(th, "Advanced settings").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - jpegQualityDisabled := state.Settings.OutFormat != deflicker.FormatJpeg + jpegQualityDisabled := controlsDisabled || state.Settings.OutFormat != deflicker.FormatJpeg return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, - layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor, false)), + layout.Flexed(1, labeledEditor(th, "Rolling average", &state.rollingAvgEditor, controlsDisabled)), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Flexed(1, labeledEditor(th, "JPEG quality", &state.jpegQualityEditor, jpegQualityDisabled)), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Flexed(1, formatSelector(th, state)), + layout.Flexed(1, formatSelector(th, state, controlsDisabled)), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), @@ -52,9 +58,8 @@ func layoutGui(gtx layout.Context, th *material.Theme, state *uiState) layout.Di if _, progressText := state.progress(); progressText != "" { text = progressText } - gtx = gtx.Disabled() } - return material.Button(th, &state.startBtn, text).Layout(gtx) + return buttonWithState(th, &state.startBtn, text, controlsDisabled)(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(20)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -64,21 +69,28 @@ func layoutGui(gtx layout.Context, th *material.Theme, state *uiState) layout.Di return bar.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - displayText := state.statusText - if state.processing { - displayText = "" - } - return material.Body2(th, displayText).Layout(gtx) - }), ) }) } -func formatSelector(th *material.Theme, state *uiState) layout.Widget { +func buttonWithState(th *material.Theme, btn *widget.Clickable, label string, disabled bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + if disabled { + gtx = gtx.Disabled() + } + return material.Button(th, btn, label).Layout(gtx) + } +} + +func formatSelector(th *material.Theme, state *uiState, disabled bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { + caption := material.Caption(th, "Output format") + if disabled { + caption.Color = disabledColor(th) + gtx = gtx.Disabled() + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(material.Caption(th, "Output format").Layout), + layout.Rigid(caption.Layout), layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatPng), "PNG").Layout), layout.Rigid(material.RadioButton(th, &state.formatEnum, string(deflicker.FormatJpeg), "JPEG").Layout), ) From d51863c1544048ea5940e46a4612d51254954701 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:17:20 +0200 Subject: [PATCH 07/15] Remove old architecture section --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 3b86140..1551594 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,3 @@ GOOS=darwin GOARCH=amd64 go build -o simple-deflicker-cli-macos-amd64 ./cmd/cli ## How does the deflickering work? The current implementation uses a technique called [histogram matching](https://en.wikipedia.org/wiki/Histogram_matching). It basically creates a list of how often a certain brighness (or rather every individual brightness level) appears, creates a [rolling average](https://en.wikipedia.org/wiki/Moving_average) to allow for gradual brightness changes (for example in a day to night transition) and finally shifts the brightness to match the "smoothed out" brightness levels. - -## How is the software structured? (only important for developers, not for users) -The software uses several other packages: -* [Imaging](https://github.com/disintegration/imaging) for loading, saving and manipulating image files. -* [dialog](https://github.com/sqweek/dialog) for creating the dialog boxes and file selection windows. -* [uiprogress](https://github.com/gosuri/uiprogress) for creating the progress bars in the console. -* [nucular](https://github.com/aarzilli/nucular) for the GUI. From 73c94cfad0aa303eaa4058cc5e68dcc5efc1fe06 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:20:11 +0200 Subject: [PATCH 08/15] Update launch.json --- .vscode/launch.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6c3d4f7..60d56e6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { From 205fd80ecaaf7b671b9fffb10c942b0c44faaed5 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:30:27 +0200 Subject: [PATCH 09/15] Update progress.go --- internal/progress/progress.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 4c102d7..2584618 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -12,7 +12,7 @@ type Updater interface { type ConsoleUpdater struct{} func (c *ConsoleUpdater) Start() { - println("Processing started...") + fmt.Println("Processing started...") } func (c *ConsoleUpdater) Increment(msg string, phase string, completed int, ofTotal int) { @@ -20,5 +20,5 @@ func (c *ConsoleUpdater) Increment(msg string, phase string, completed int, ofTo } func (c *ConsoleUpdater) Finish() { - println("Processing finished.") + fmt.Println("Processing finished.") } From f22f416b5e6b20be5126d69ad11f7c44f94c6448 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:30:41 +0200 Subject: [PATCH 10/15] Update launch.json --- .vscode/launch.json | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 60d56e6..920c22b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,12 +1,41 @@ { + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "Launch GUI", + "name": "GUI", "type": "go", "request": "launch", "mode": "auto", - "program": "./cmd/gui" + "program": "${workspaceFolder}/cmd/gui" + }, + { + "name": "CLI - Church (fetch github.com/struffel/simple-deflicker-test-data into ../simple-deflicker-test-data first)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/cli", + "args": [ + "-source", + "${workspaceFolder}/../simple-deflicker-test-data/real/church", + "-destination", + "${workspaceFolder}/../simple-deflicker-test-data/real/church/deflickered", + ] + }, + { + "name": "CLI - Clouds (fetch github.com/struffel/simple-deflicker-test-data into ../simple-deflicker-test-data first)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/cli", + "args": [ + "-source", + "${workspaceFolder}/../simple-deflicker-test-data/real/clouds", + "-destination", + "${workspaceFolder}/../simple-deflicker-test-data/real/clouds/deflickered", + ] } ] } \ No newline at end of file From ac96f19ba60cfbdee0957f224bb84674b3aebdf5 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:38:26 +0200 Subject: [PATCH 11/15] Update settings.go --- internal/deflicker/settings.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/deflicker/settings.go b/internal/deflicker/settings.go index b8c4a84..a1be846 100644 --- a/internal/deflicker/settings.go +++ b/internal/deflicker/settings.go @@ -34,9 +34,11 @@ type Settings struct { // DefaultSettings returns the settings the GUI is pre-populated and that the CLI uses if no arguments are provided. func DefaultSettings() Settings { return Settings{ - RollingAverage: 15, - OutFormat: FormatPng, - JpegQuality: 95, + SourceDirectory: "", + DestinationDirectory: "", + RollingAverage: 15, + OutFormat: FormatPng, + JpegQuality: 95, } } @@ -44,8 +46,8 @@ func NewSettingsFromArgs() Settings { var defaultSettings = DefaultSettings() var settings Settings var tmpFormat string - flag.StringVar(&settings.SourceDirectory, "source", "", "Directory with the images to process.") - flag.StringVar(&settings.DestinationDirectory, "destination", "", "Directory to put the processed images in.") + flag.StringVar(&settings.SourceDirectory, "source", defaultSettings.SourceDirectory, "Directory with the images to process.") + flag.StringVar(&settings.DestinationDirectory, "destination", defaultSettings.DestinationDirectory, "Directory to put the processed images in.") flag.IntVar(&settings.RollingAverage, "rollingAverage", defaultSettings.RollingAverage, "Number of frames to use for rolling average. 0 disables it.") flag.StringVar(&tmpFormat, "format", string(defaultSettings.OutFormat), "Output format. Options are jpeg png.") flag.IntVar(&settings.JpegQuality, "jpegQuality", defaultSettings.JpegQuality, "Level of JPEG compression. Must be between 1 - 100.") From 4c86d4bdb1678a29a26b0226f3ee2488619671f5 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:40:20 +0200 Subject: [PATCH 12/15] Update go mod --- go.mod | 8 +------- go.sum | 13 ------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/go.mod b/go.mod index ac62582..a64ad96 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,8 @@ go 1.25.0 require ( gioui.org v0.10.1 github.com/disintegration/imaging v1.6.2 - github.com/gosuri/uiprogress v0.0.1 - github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 github.com/ncruces/zenity v0.10.14 + golang.org/x/sync v0.22.0 ) require ( @@ -15,16 +14,11 @@ require ( github.com/akavel/rsrc v0.10.2 // indirect github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect github.com/go-text/typesetting v0.3.4 // indirect - github.com/gosuri/uilive v0.0.4 // indirect github.com/josephspurrier/goversioninfo v1.4.1 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/image v0.26.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 20cc7bc..907160a 100644 --- a/go.sum +++ b/go.sum @@ -17,16 +17,8 @@ github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaF github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= -github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= -github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= -github.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw= -github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= -github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 h1:fO9A67/izFYFYky7l1pDP5Dr0BTCRkaQJUG6Jm5ehsk= -github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3/go.mod h1:Ey4uAp+LvIl+s5jRbOHLcZpUDnkjLBROl15fZLwPlTM= github.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY= github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -38,8 +30,6 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= @@ -51,11 +41,8 @@ golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= From d82450c58d8a163cbbabdf486ec204998e8b7e72 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:24:21 +0200 Subject: [PATCH 13/15] Update README.md --- README.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1551594..c1e5c6e 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ A minimalist, easy to use tool for deflickering image sequences such as timelaps Timelapse flickering can occur if one or more settings of the camera have been left on "auto" which causes it to randomly switch between two settings (for example shutter speeds). ## How to use this software -* Download the latest version from the [releases page](https://github.com/StruffelProductions/simple-deflicker/releases). Prebuilt binaries are provided for Windows, and macOS builds are CLI-only. -* Execute `simple-deflicker.exe` on Windows to use the GUI. Check the console for error messages. -![image](https://user-images.githubusercontent.com/31403260/115123359-f2bbe400-9fbc-11eb-84d7-29615c5030fb.png) +* Download the latest version from the [releases page](https://github.com/struffel/simple-deflicker/releases). Prebuilt binaries are provided only for Windows at this time. +* Execute `simple-deflicker.exe` to use the GUI or `simple-deflicker-cli.exe` to use the CLI version. ## CLI usage Simple Deflicker can run without the GUI by passing a source and destination directory: @@ -24,6 +23,7 @@ Optional flags: simple-deflicker-cli -source "/path/to/input" -destination "/path/to/output" -rollingAverage 15 -format png -jpegQuality 95 ``` +## Building from source The GUI and CLI are separate binaries, built from `./cmd/gui` and `./cmd/cli` respectively: ```bash @@ -31,14 +31,6 @@ go build -o simple-deflicker ./cmd/gui go build -o simple-deflicker-cli ./cmd/cli ``` -macOS builds only support the CLI binary: - -```bash -GOOS=darwin GOARCH=arm64 go build -o simple-deflicker-cli-macos-arm64 ./cmd/cli -GOOS=darwin GOARCH=amd64 go build -o simple-deflicker-cli-macos-amd64 ./cmd/cli -``` - - ## Current limitations of the tool * Only JPG and PNG (8bit) are supported * JPGs will always be saved with a compression setting of 95 From bb7cba6101f97ee47e6345090f35d34ccd0e6a80 Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:25:39 +0200 Subject: [PATCH 14/15] Try building for all platfoms --- .github/workflows/go.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 30b35ed..601a0d2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -12,7 +12,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -21,7 +25,8 @@ jobs: with: go-version: '1.25' - - name: Install system dependencies + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libvulkan-dev libx11-dev libx11-xcb-dev libxi-dev libxext-dev libegl-dev libgles-dev libxcursor-dev libxrandr-dev libxinerama-dev libxfixes-dev libgtk-3-dev pkg-config From 1bca4caeaa58d0d8828403e836fe996294ad5c5b Mon Sep 17 00:00:00 2001 From: Lennart Demes <31403260+struffel@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:32:43 +0200 Subject: [PATCH 15/15] Update comments --- internal/ui/gui.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/ui/gui.go b/internal/ui/gui.go index 96fefe5..035ab53 100644 --- a/internal/ui/gui.go +++ b/internal/ui/gui.go @@ -17,8 +17,6 @@ import ( "github.com/struffel/simple-deflicker/internal/deflicker" ) -// StartGUI launches the graphical user interface and blocks until the -// window is closed. func StartGUI() error { go func() { w := new(app.Window) @@ -53,8 +51,7 @@ func runWindow(w *app.Window) error { } // receiveGuiResults applies any results delivered by background goroutines -// (directory pickers, deflickering) to the widget state. It must only be -// called from the goroutine that owns the window. +// (directory pickers, deflickering) to the widget state. func receiveGuiResults(state *uiState) { select { case dir := <-state.sourceResult: @@ -147,8 +144,7 @@ func startDeflickering(w *app.Window, state *uiState) { } // guiUpdater implements progress.Updater by writing progress into the UiState -// and invalidating the window so the native progress bar redraws. It is safe -// for use from background goroutines. +// and invalidating the window so the native progress bar redraws. type guiUpdater struct { state *uiState win *app.Window