Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/build-linux-amd64.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Build Linux AMD64

on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
env:
CGO_ENABLED: "0"
steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: go.sum

- name: Test
run: go test ./...

- name: Build Linux AMD64 binary
run: |
mkdir -p dist
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/primitive-web-linux-amd64 ./cmd/web
sha256sum dist/primitive-web-linux-amd64 > dist/primitive-web-linux-amd64.sha256

- name: Upload binary
uses: actions/upload-artifact@v6
with:
name: primitive-web-linux-amd64
path: |
dist/primitive-web-linux-amd64
dist/primitive-web-linux-amd64.sha256
if-no-files-found: error
retention-days: 1
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*.png
/*.svg
/*.gif

/data/
/primitive-web
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ You can tweet a picture to the bot and it will process it for you.

Run it on your own images! First, [install Go](https://golang.org/doc/install).

go get -u github.com/fogleman/primitive
go install github.com/FiyZou/primitive@latest
primitive -i input.png -o output.png -n 100

Small input images should be used (like 256x256px). You don't need the detail anyway and the code will run faster.
Expand All @@ -48,6 +48,72 @@ Small input images should be used (like 256x256px). You don't need the detail an
| `v` | off | verbose output |
| `vv` | off | very verbose output |

### Web Studio

Primitive also includes a self-hosted browser interface for uploading images,
building multi-stage shape configurations, watching live progress, and
downloading PNG, JPG, SVG, or GIF results.

Requires Go 1.25 or newer.

git clone https://github.com/FiyZou/primitive.git
cd primitive
go run ./cmd/web

Open <http://127.0.0.1:8080>. Jobs run one at a time and are stored in
`./data` using SQLite plus per-job input and output files. Both settings can be
overridden:

PRIMITIVE_ADDR=0.0.0.0:8080 PRIMITIVE_DATA_DIR=/path/to/data go run ./cmd/web

The default loopback address is intentional: this first version has no account
system and is designed for a single trusted user. Put authentication and TLS in
front of it before exposing it to a network.

The Web form exposes the algorithm settings that apply to a generation task:
input and output size, background color, worker count, ordered shape stages,
alpha, repeat count, and output formats. CLI-only frame naming and debug flags
(`nth`, `v`, and `vv`) remain available from the command line.

Uploaded files are limited to 20 MB and 40 million decoded pixels. Completed
jobs remain available across restarts until they are deleted from the Web UI.

#### Linux AMD64 build

The `Build Linux AMD64` GitHub Actions workflow runs tests and uploads
`primitive-web-linux-amd64` plus its SHA-256 checksum for pushes to `master`,
pull requests, version tags, and manual runs. Download the artifact from the
workflow run within one day; the workflow uses GitHub's minimum one-day
artifact retention to limit storage usage. Then install the executable:

sha256sum -c primitive-web-linux-amd64.sha256
sudo install -m 0755 primitive-web-linux-amd64 /usr/local/bin/primitive-web

#### systemd

Create the unprivileged service account and install the included unit:

sudo useradd --system --user-group --home-dir /var/lib/primitive --shell /usr/sbin/nologin primitive
sudo install -m 0644 deploy/primitive-web.service /etc/systemd/system/primitive-web.service
sudo systemctl daemon-reload
sudo systemctl enable --now primitive-web
sudo systemctl status primitive-web

The unit stores SQLite and job files in `/var/lib/primitive` and listens on
`127.0.0.1:8080`. To override settings, create `/etc/default/primitive-web`
before restarting the service:

PRIMITIVE_ADDR=0.0.0.0:8080
PRIMITIVE_DATA_DIR=/var/lib/primitive

Apply changes and inspect logs with:

sudo systemctl restart primitive-web
sudo journalctl -u primitive-web -f

If `PRIMITIVE_DATA_DIR` is changed, update `ReadWritePaths` in the unit to the
same directory because the service uses systemd filesystem protection.

### Output Formats

Depending on the output filename extension provided, you can produce different types of output.
Expand Down
61 changes: 61 additions & 0 deletions cmd/web/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/FiyZou/primitive/internal/webapp"
)

func main() {
address := envOr("PRIMITIVE_ADDR", "127.0.0.1:8080")
dataDir := envOr("PRIMITIVE_DATA_DIR", "./data")
app, err := webapp.New(dataDir)
if err != nil {
log.Fatal(err)
}
app.Start()

server := &http.Server{
Addr: address, Handler: app.Handler(), ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
errorsCh := make(chan error, 1)
go func() {
log.Printf("Primitive Studio listening on http://%s", address)
errorsCh <- server.ListenAndServe()
}()

signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err = <-errorsCh:
case <-signalCtx.Done():
app.Stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err = server.Shutdown(shutdownCtx)
cancel()
if err == nil {
err = <-errorsCh
}
}
if closeErr := app.Close(); err == nil {
err = closeErr
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}

func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
36 changes: 36 additions & 0 deletions deploy/primitive-web.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[Unit]
Description=Primitive Studio web service
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=primitive
Group=primitive
WorkingDirectory=/var/lib/primitive
StateDirectory=primitive
StateDirectoryMode=0700
Environment=PRIMITIVE_ADDR=127.0.0.1:8080
Environment=PRIMITIVE_DATA_DIR=/var/lib/primitive
EnvironmentFile=-/etc/default/primitive-web
ExecStart=/usr/local/bin/primitive-web
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
UMask=0077

NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
ReadWritePaths=/var/lib/primitive
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
SystemCallArchitectures=native

[Install]
WantedBy=multi-user.target
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module github.com/FiyZou/primitive

go 1.25.0

require (
github.com/fogleman/gg v1.3.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
golang.org/x/image v0.44.0
modernc.org/sqlite v1.40.1
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
57 changes: 57 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
Loading