From 5ba88cff1dd318c73b26a8eed4df468349d0bf07 Mon Sep 17 00:00:00 2001 From: Aristo Date: Fri, 17 Jul 2026 18:02:54 +0800 Subject: [PATCH] Add Primitive web studio and deployment --- .github/workflows/build-linux-amd64.yml | 46 +++ .gitignore | 3 +- README.md | 68 +++- cmd/web/main.go | 61 ++++ deploy/primitive-web.service | 36 +++ go.mod | 24 ++ go.sum | 57 ++++ internal/generator/generator.go | 203 ++++++++++++ internal/generator/generator_test.go | 76 +++++ internal/webapp/app.go | 398 ++++++++++++++++++++++++ internal/webapp/server.go | 326 +++++++++++++++++++ internal/webapp/store.go | 281 +++++++++++++++++ internal/webapp/web/app.js | 130 ++++++++ internal/webapp/web/index.html | 77 +++++ internal/webapp/web/style.css | 4 + internal/webapp/webapp_test.go | 235 ++++++++++++++ main.go | 101 +++--- primitive/raster.go | 2 +- primitive/util.go | 2 +- 19 files changed, 2061 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/build-linux-amd64.yml create mode 100644 cmd/web/main.go create mode 100644 deploy/primitive-web.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/generator/generator.go create mode 100644 internal/generator/generator_test.go create mode 100644 internal/webapp/app.go create mode 100644 internal/webapp/server.go create mode 100644 internal/webapp/store.go create mode 100644 internal/webapp/web/app.js create mode 100644 internal/webapp/web/index.html create mode 100644 internal/webapp/web/style.css create mode 100644 internal/webapp/webapp_test.go diff --git a/.github/workflows/build-linux-amd64.yml b/.github/workflows/build-linux-amd64.yml new file mode 100644 index 00000000..1cdf6b05 --- /dev/null +++ b/.github/workflows/build-linux-amd64.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3b9b6e9a..2060c4f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /*.png /*.svg /*.gif - +/data/ +/primitive-web diff --git a/README.md b/README.md index ebe8af4b..5b42d14b 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 . 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. diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 00000000..9ad337dd --- /dev/null +++ b/cmd/web/main.go @@ -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 +} diff --git a/deploy/primitive-web.service b/deploy/primitive-web.service new file mode 100644 index 00000000..48758cb0 --- /dev/null +++ b/deploy/primitive-web.service @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..c347b785 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..b2aa267d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 00000000..f10338a6 --- /dev/null +++ b/internal/generator/generator.go @@ -0,0 +1,203 @@ +package generator + +import ( + "context" + "errors" + "fmt" + "image" + "regexp" + "runtime" + "strings" + + "github.com/FiyZou/primitive/primitive" + "github.com/nfnt/resize" +) + +type Mode string + +const ( + ModeCombo Mode = "combo" + ModeTriangle Mode = "triangle" + ModeRectangle Mode = "rectangle" + ModeEllipse Mode = "ellipse" + ModeCircle Mode = "circle" + ModeRotatedRectangle Mode = "rotated_rectangle" + ModeQuadratic Mode = "quadratic" + ModeRotatedEllipse Mode = "rotated_ellipse" + ModePolygon Mode = "polygon" +) + +var modes = map[Mode]primitive.ShapeType{ + ModeCombo: primitive.ShapeTypeAny, ModeTriangle: primitive.ShapeTypeTriangle, + ModeRectangle: primitive.ShapeTypeRectangle, ModeEllipse: primitive.ShapeTypeEllipse, + ModeCircle: primitive.ShapeTypeCircle, ModeRotatedRectangle: primitive.ShapeTypeRotatedRectangle, + ModeQuadratic: primitive.ShapeTypeQuadratic, ModeRotatedEllipse: primitive.ShapeTypeRotatedEllipse, + ModePolygon: primitive.ShapeTypePolygon, +} + +var hexColor = regexp.MustCompile(`^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) + +type Stage struct { + Count int `json:"count"` + Mode Mode `json:"mode"` + Alpha int `json:"alpha"` + Repeat int `json:"repeat"` +} + +type Config struct { + InputSize int `json:"inputSize"` + OutputSize int `json:"outputSize"` + Background string `json:"background"` + Workers int `json:"workers"` + Formats []string `json:"formats"` + Stages []Stage `json:"stages"` +} + +type Progress struct { + Completed int + Total int + Score float64 + Model *primitive.Model +} + +type ProgressFunc func(Progress) error + +func DefaultConfig() Config { + return Config{ + InputSize: 256, OutputSize: 1024, Formats: []string{"png", "svg"}, + Stages: []Stage{{Count: 100, Mode: ModeTriangle, Alpha: 128}}, + } +} + +func ModeFromShapeType(t primitive.ShapeType) (Mode, bool) { + for mode, shapeType := range modes { + if shapeType == t { + return mode, true + } + } + return "", false +} + +func (m Mode) ShapeType() (primitive.ShapeType, bool) { + t, ok := modes[m] + return t, ok +} + +func (c Config) ValidateWeb() error { + if c.InputSize != 0 && (c.InputSize < 16 || c.InputSize > 2048) { + return errors.New("inputSize must be 0 or between 16 and 2048") + } + if c.OutputSize < 16 || c.OutputSize > 4096 { + return errors.New("outputSize must be between 16 and 4096") + } + if c.Workers < 0 || c.Workers > runtime.NumCPU() { + return fmt.Errorf("workers must be between 0 and %d", runtime.NumCPU()) + } + if c.Background != "" && !hexColor.MatchString(c.Background) { + return errors.New("background must be a 3, 4, 6, or 8 digit hex color") + } + if len(c.Stages) < 1 || len(c.Stages) > 20 { + return errors.New("stages must contain between 1 and 20 items") + } + total := 0 + for i, stage := range c.Stages { + if stage.Count < 1 { + return fmt.Errorf("stages[%d].count must be greater than 0", i) + } + if _, ok := stage.Mode.ShapeType(); !ok { + return fmt.Errorf("stages[%d].mode is invalid", i) + } + if stage.Alpha < 0 || stage.Alpha > 255 { + return fmt.Errorf("stages[%d].alpha must be between 0 and 255", i) + } + if stage.Repeat < 0 || stage.Repeat > 10 { + return fmt.Errorf("stages[%d].repeat must be between 0 and 10", i) + } + total += stage.Count + } + if total > 2000 { + return errors.New("total stage count must not exceed 2000") + } + if len(c.Formats) == 0 { + return errors.New("at least one output format is required") + } + seen := make(map[string]bool, len(c.Formats)) + for _, format := range c.Formats { + format = strings.ToLower(format) + if format != "png" && format != "jpg" && format != "svg" && format != "gif" { + return fmt.Errorf("unsupported output format %q", format) + } + if seen[format] { + return fmt.Errorf("duplicate output format %q", format) + } + seen[format] = true + } + return nil +} + +func Run(ctx context.Context, input image.Image, config Config, progress ProgressFunc) (*primitive.Model, error) { + if input == nil { + return nil, errors.New("input image is required") + } + if config.OutputSize < 1 { + return nil, errors.New("output size must be greater than 0") + } + if len(config.Stages) == 0 { + return nil, errors.New("at least one stage is required") + } + if config.InputSize > 0 { + size := uint(config.InputSize) + input = resize.Thumbnail(size, size, input, resize.Bilinear) + } + background := primitive.MakeColor(primitive.AverageImageColor(input)) + if config.Background != "" { + if !hexColor.MatchString(config.Background) { + return nil, errors.New("invalid background color") + } + background = primitive.MakeHexColor(config.Background) + } + workers := config.Workers + if workers < 1 { + workers = runtime.NumCPU() + } + model := primitive.NewModel(input, background, config.OutputSize, workers) + total := 0 + for _, stage := range config.Stages { + total += stage.Count + } + completed := 0 + for _, stage := range config.Stages { + shapeType, ok := stage.Mode.ShapeType() + if !ok { + return nil, fmt.Errorf("invalid mode %q", stage.Mode) + } + for range stage.Count { + if err := ctx.Err(); err != nil { + return model, err + } + model.Step(shapeType, stage.Alpha, stage.Repeat) + completed++ + if progress != nil { + if err := progress(Progress{Completed: completed, Total: total, Score: model.Score, Model: model}); err != nil { + return model, err + } + } + } + } + return model, nil +} + +func SaveOutput(path, format string, model *primitive.Model) error { + switch strings.ToLower(format) { + case "png": + return primitive.SavePNG(path, model.Context.Image()) + case "jpg", "jpeg": + return primitive.SaveJPG(path, model.Context.Image(), 95) + case "svg": + return primitive.SaveFile(path, model.SVG()) + case "gif": + return primitive.SaveGIF(path, model.Frames(0.001), 5, 25) + default: + return fmt.Errorf("unsupported output format %q", format) + } +} diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go new file mode 100644 index 00000000..23dc4913 --- /dev/null +++ b/internal/generator/generator_test.go @@ -0,0 +1,76 @@ +package generator + +import ( + "context" + "errors" + "image" + "image/color" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateWeb(t *testing.T) { + config := DefaultConfig() + if err := config.ValidateWeb(); err != nil { + t.Fatalf("default config is invalid: %v", err) + } + config.Background = "not-a-color" + if err := config.ValidateWeb(); err == nil { + t.Fatal("invalid background was accepted") + } + config = DefaultConfig() + config.Stages[0].Mode = "unknown" + if err := config.ValidateWeb(); err == nil { + t.Fatal("invalid mode was accepted") + } +} + +func TestRunCancelAndSaveFormats(t *testing.T) { + input := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + input.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 30), B: 90, A: 255}) + } + } + config := Config{ + InputSize: 0, OutputSize: 16, Workers: 1, Formats: []string{"png", "jpg", "svg", "gif"}, + Stages: []Stage{{Count: 1, Mode: ModeTriangle, Alpha: 128}}, + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Run(canceled, input, config, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("Run error = %v, want context.Canceled", err) + } + + progressCalls := 0 + model, err := Run(context.Background(), input, config, func(progress Progress) error { + progressCalls++ + if progress.Completed != 1 || progress.Total != 1 { + t.Fatalf("unexpected progress: %#v", progress) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if progressCalls != 1 { + t.Fatalf("progress calls = %d, want 1", progressCalls) + } + + dir := t.TempDir() + for _, format := range config.Formats { + path := filepath.Join(dir, "result."+format) + if err := SaveOutput(path, format, model); err != nil { + t.Fatalf("save %s: %v", format, err) + } + if info, err := os.Stat(path); err != nil || info.Size() == 0 { + t.Fatalf("output %s was not created", format) + } + } + data, err := os.ReadFile(filepath.Join(dir, "result.svg")) + if err != nil || !strings.Contains(string(data), " maxUploadBytes { + return nil, "", errors.New("input image exceeds 20 MB") + } + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || (format != "png" && format != "jpeg" && format != "gif") { + return nil, "", errors.New("input must be a PNG, JPEG, or GIF image") + } + if config.Width < 1 || config.Height < 1 || int64(config.Width)*int64(config.Height) > maxImagePixels { + return nil, "", errors.New("input image must not exceed 40 million pixels") + } + return data, format, nil +} + +func writeEvent(w io.Writer, job Job) error { + data, err := json.Marshal(job) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "event: job\ndata: %s\n\n", data) + return err +} + +func serveExistingFile(w http.ResponseWriter, r *http.Request, path, contentType, downloadName string) { + if _, err := os.Stat(path); err != nil { + writeError(w, http.StatusNotFound, errors.New("file is not available")) + return + } + w.Header().Set("Content-Type", contentType) + if downloadName != "" { + w.Header().Set("Content-Disposition", `attachment; filename="`+downloadName+`"`) + } + http.ServeFile(w, r, path) +} + +func sameOrigin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + origin := r.Header.Get("Origin") + if origin != "" { + u, err := url.Parse(origin) + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if err != nil || u.Scheme != scheme || u.Host != r.Host { + writeError(w, http.StatusForbidden, errors.New("cross-origin write request rejected")) + return + } + } + } + next.ServeHTTP(w, r) + }) +} + +func writeStoreError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, sql.ErrNoRows): + writeError(w, http.StatusNotFound, errors.New("job not found")) + case errors.Is(err, ErrConflict): + writeError(w, http.StatusConflict, err) + default: + writeError(w, http.StatusInternalServerError, err) + } +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func logServerError(w http.ResponseWriter, err error) { + log.Printf("render web UI: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) +} diff --git a/internal/webapp/store.go b/internal/webapp/store.go new file mode 100644 index 00000000..ea60edec --- /dev/null +++ b/internal/webapp/store.go @@ -0,0 +1,281 @@ +package webapp + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/FiyZou/primitive/internal/generator" + _ "modernc.org/sqlite" +) + +type Status string + +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" + StatusCanceled Status = "canceled" + StatusInterrupted Status = "interrupted" +) + +var validJobID = regexp.MustCompile(`^[0-9a-f]{32}$`) + +type Job struct { + ID string `json:"id"` + Status Status `json:"status"` + OriginalName string `json:"originalName"` + InputExt string `json:"-"` + Config generator.Config `json:"config"` + Completed int `json:"completed"` + Total int `json:"total"` + Score float64 `json:"score"` + PreviewVersion int64 `json:"previewVersion"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` +} + +type Store struct { + db *sql.DB + dataDir string +} + +func NewStore(dataDir string) (*Store, error) { + if err := os.MkdirAll(filepath.Join(dataDir, "jobs"), 0o700); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", filepath.Join(dataDir, "primitive.db")) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + store := &Store{db: db, dataDir: dataDir} + if err := store.init(); err != nil { + db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) init() error { + for _, statement := range []string{ + `PRAGMA journal_mode=WAL`, + `PRAGMA busy_timeout=5000`, + `CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + original_name TEXT NOT NULL, + input_ext TEXT NOT NULL, + config_json TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL, + score REAL NOT NULL DEFAULT 0, + preview_version INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT '', + finished_at TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS jobs_status_created ON jobs(status, created_at)`, + `PRAGMA user_version=1`, + } { + if _, err := s.db.Exec(statement); err != nil { + return err + } + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.Exec(`UPDATE jobs SET status=?, error=?, finished_at=? WHERE status=?`, + StatusInterrupted, "service stopped before completion", now, StatusRunning) + return err +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) JobDir(id string) (string, error) { + if !validJobID.MatchString(id) { + return "", errors.New("invalid job id") + } + return filepath.Join(s.dataDir, "jobs", id), nil +} + +func (s *Store) InputPath(job Job) (string, error) { + dir, err := s.JobDir(job.ID) + if err != nil { + return "", err + } + return filepath.Join(dir, "input"+job.InputExt), nil +} + +func (s *Store) Create(ctx context.Context, input []byte, originalName, inputExt string, config generator.Config) (Job, error) { + id, err := newJobID() + if err != nil { + return Job{}, err + } + dir, _ := s.JobDir(id) + if err := os.Mkdir(dir, 0o700); err != nil { + return Job{}, err + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(dir) + } + }() + if err := os.WriteFile(filepath.Join(dir, "input"+inputExt), input, 0o600); err != nil { + return Job{}, err + } + configJSON, err := json.Marshal(config) + if err != nil { + return Job{}, err + } + total := 0 + for _, stage := range config.Stages { + total += stage.Count + } + job := Job{ + ID: id, Status: StatusQueued, OriginalName: originalName, InputExt: inputExt, + Config: config, Total: total, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + _, err = s.db.ExecContext(ctx, `INSERT INTO jobs + (id,status,original_name,input_ext,config_json,total,created_at) VALUES(?,?,?,?,?,?,?)`, + job.ID, job.Status, job.OriginalName, job.InputExt, string(configJSON), job.Total, job.CreatedAt) + if err != nil { + return Job{}, err + } + cleanup = false + return job, nil +} + +func (s *Store) Get(ctx context.Context, id string) (Job, error) { + if !validJobID.MatchString(id) { + return Job{}, sql.ErrNoRows + } + return scanJob(s.db.QueryRowContext(ctx, `SELECT id,status,original_name,input_ext,config_json, + completed,total,score,preview_version,error,created_at,started_at,finished_at FROM jobs WHERE id=?`, id)) +} + +func (s *Store) List(ctx context.Context) ([]Job, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,status,original_name,input_ext,config_json, + completed,total,score,preview_version,error,created_at,started_at,finished_at + FROM jobs ORDER BY created_at DESC LIMIT 100`) + if err != nil { + return nil, err + } + defer rows.Close() + var jobs []Job + for rows.Next() { + job, err := scanJob(rows) + if err != nil { + return nil, err + } + jobs = append(jobs, job) + } + return jobs, rows.Err() +} + +func (s *Store) ClaimNext(ctx context.Context) (Job, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Job{}, err + } + defer tx.Rollback() + job, err := scanJob(tx.QueryRowContext(ctx, `SELECT id,status,original_name,input_ext,config_json, + completed,total,score,preview_version,error,created_at,started_at,finished_at + FROM jobs WHERE status=? ORDER BY created_at LIMIT 1`, StatusQueued)) + if err != nil { + return Job{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + result, err := tx.ExecContext(ctx, `UPDATE jobs SET status=?, started_at=?, finished_at='', error='' + WHERE id=? AND status=?`, StatusRunning, now, job.ID, StatusQueued) + if err != nil { + return Job{}, err + } + changed, _ := result.RowsAffected() + if changed != 1 { + return Job{}, sql.ErrNoRows + } + if err := tx.Commit(); err != nil { + return Job{}, err + } + job.Status, job.StartedAt, job.FinishedAt, job.Error = StatusRunning, now, "", "" + return job, nil +} + +func (s *Store) UpdateProgress(ctx context.Context, id string, completed int, score float64, previewVersion int64) (Job, error) { + _, err := s.db.ExecContext(ctx, `UPDATE jobs SET completed=?,score=?,preview_version=? WHERE id=? AND status=?`, + completed, score, previewVersion, id, StatusRunning) + if err != nil { + return Job{}, err + } + return s.Get(ctx, id) +} + +func (s *Store) Finish(ctx context.Context, id string, status Status, completed int, score float64, message string) (Job, error) { + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, `UPDATE jobs SET status=?,completed=?,score=?,error=?,finished_at=? WHERE id=?`, + status, completed, score, message, now, id) + if err != nil { + return Job{}, err + } + return s.Get(ctx, id) +} + +func (s *Store) CancelQueued(ctx context.Context, id string) (bool, error) { + now := time.Now().UTC().Format(time.RFC3339Nano) + result, err := s.db.ExecContext(ctx, `UPDATE jobs SET status=?,finished_at=? WHERE id=? AND status=?`, + StatusCanceled, now, id, StatusQueued) + if err != nil { + return false, err + } + changed, _ := result.RowsAffected() + return changed == 1, nil +} + +func (s *Store) Delete(ctx context.Context, id string) (bool, error) { + if !validJobID.MatchString(id) { + return false, nil + } + result, err := s.db.ExecContext(ctx, `DELETE FROM jobs WHERE id=? AND status<>?`, id, StatusRunning) + if err != nil { + return false, err + } + changed, _ := result.RowsAffected() + return changed == 1, nil +} + +type scanner interface{ Scan(...any) error } + +func scanJob(row scanner) (Job, error) { + var job Job + var configJSON string + err := row.Scan(&job.ID, &job.Status, &job.OriginalName, &job.InputExt, &configJSON, + &job.Completed, &job.Total, &job.Score, &job.PreviewVersion, &job.Error, + &job.CreatedAt, &job.StartedAt, &job.FinishedAt) + if err != nil { + return Job{}, err + } + if err := json.Unmarshal([]byte(configJSON), &job.Config); err != nil { + return Job{}, fmt.Errorf("decode job %s config: %w", job.ID, err) + } + return job, nil +} + +func newJobID() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", err + } + return hex.EncodeToString(value[:]), nil +} diff --git a/internal/webapp/web/app.js b/internal/webapp/web/app.js new file mode 100644 index 00000000..20de7d15 --- /dev/null +++ b/internal/webapp/web/app.js @@ -0,0 +1,130 @@ +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const state = { jobs: [], selected: null, events: null }; +const statusNames = { queued: '排队中', running: '生成中', succeeded: '已完成', failed: '失败', canceled: '已取消', interrupted: '已中断' }; + +function addStage(value = {}) { + const row = $('#stage-template').content.firstElementChild.cloneNode(true); + $('.stage-mode', row).value = value.mode || 'triangle'; + $('.stage-count', row).value = value.count ?? 100; + $('.stage-alpha', row).value = value.alpha ?? 128; + $('.stage-repeat', row).value = value.repeat ?? 0; + row.addEventListener('click', event => { + const action = event.target.dataset.action; + if (!action) return; + if (action === 'remove' && $$('.stage-row').length > 1) row.remove(); + if (action === 'up' && row.previousElementSibling) row.parentNode.insertBefore(row, row.previousElementSibling); + if (action === 'down' && row.nextElementSibling) row.parentNode.insertBefore(row.nextElementSibling, row); + renumberStages(); + }); + $('#stages').append(row); + renumberStages(); +} + +function renumberStages() { $$('.stage-row').forEach((row, i) => $('.stage-index', row).textContent = String(i + 1).padStart(2, '0')); } + +function collectConfig() { + return { + inputSize: Number($('#input-size').value), outputSize: Number($('#output-size').value), + background: $('#background').value.trim(), workers: Number($('#workers').value), + formats: $$('input[name="format"]:checked').map(input => input.value), + stages: $$('.stage-row').map(row => ({ + mode: $('.stage-mode', row).value, count: Number($('.stage-count', row).value), + alpha: Number($('.stage-alpha', row).value), repeat: Number($('.stage-repeat', row).value) + })) + }; +} + +async function request(url, options = {}) { + const response = await fetch(url, options); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `请求失败 (${response.status})`); + } + return response.status === 204 ? null : response.json(); +} + +async function loadJobs() { + try { + state.jobs = await request('/api/jobs'); + renderJobs(); + if (state.selected) { + const current = state.jobs.find(job => job.id === state.selected); + if (current) renderDetail(current); else selectJob(null); + } + } catch (error) { $('#jobs').textContent = error.message; } +} + +function renderJobs() { + const container = $('#jobs'); container.replaceChildren(); + if (!state.jobs.length) { const p = document.createElement('p'); p.className = 'empty'; p.textContent = '还没有任务'; container.append(p); return; } + state.jobs.forEach(job => { + const card = document.createElement('article'); card.className = `job-card${job.id === state.selected ? ' selected' : ''}`; + const percent = job.total ? Math.round(job.completed / job.total * 100) : 0; + const top = document.createElement('div'); top.className = 'job-card-top'; + const name = document.createElement('span'); name.className = 'job-name'; name.textContent = job.originalName; + const badge = document.createElement('span'); badge.className = `badge ${job.status}`; badge.textContent = statusNames[job.status] || job.status; + top.append(name, badge); + const progress = document.createElement('div'); progress.className = 'progress'; progress.innerHTML = ``; + const meta = document.createElement('div'); meta.className = 'job-meta'; + const time = new Date(job.createdAt).toLocaleString(); meta.innerHTML = `${percent}% · ${job.completed}/${job.total}${time}`; + card.append(top, progress, meta); card.addEventListener('click', () => selectJob(job.id)); container.append(card); + }); +} + +function selectJob(id) { + state.selected = id; + if (state.events) { state.events.close(); state.events = null; } + renderJobs(); + if (!id) { $('#detail').className = 'detail empty-detail'; $('#detail').innerHTML = '

选择一个任务查看实时进度和结果。

'; return; } + const job = state.jobs.find(item => item.id === id); if (job) renderDetail(job); + state.events = new EventSource(`/api/jobs/${id}/events`); + state.events.addEventListener('job', event => { + const update = JSON.parse(event.data); const index = state.jobs.findIndex(item => item.id === update.id); + if (index >= 0) state.jobs[index] = update; else state.jobs.unshift(update); + renderJobs(); if (state.selected === update.id) renderDetail(update); + }); +} + +function renderDetail(job) { + const detail = $('#detail'); detail.className = 'detail'; detail.replaceChildren(); + const preview = document.createElement('div'); preview.className = 'detail-preview'; + if (job.previewVersion) { const img = document.createElement('img'); img.src = `/api/jobs/${job.id}/preview?v=${job.previewVersion}`; img.alt = '生成结果预览'; preview.append(img); } + else preview.textContent = job.status === 'queued' ? '等待执行' : '预览生成中…'; + const title = document.createElement('h3'); title.textContent = job.originalName; + const summary = document.createElement('p'); summary.className = 'detail-summary'; summary.textContent = `${statusNames[job.status]} · ${job.config.stages.length} 个阶段`; + const grid = document.createElement('div'); grid.className = 'detail-grid'; + grid.innerHTML = `
进度${job.completed}/${job.total}
分数${job.score ? job.score.toFixed(6) : '—'}
输出尺寸${job.config.outputSize}px
`; + detail.append(preview, title, summary, grid); + if (job.error) { const error = document.createElement('p'); error.className = 'error-box'; error.textContent = job.error; detail.append(error); } + const actions = document.createElement('div'); actions.className = 'job-actions'; + if (job.status === 'queued' || job.status === 'running') actions.append(actionButton('取消任务', () => mutate(`/api/jobs/${job.id}/cancel`, 'POST'))); + if (['succeeded','failed','canceled','interrupted'].includes(job.status)) actions.append(actionButton('再次运行', () => retry(job.id))); + if (job.status === 'succeeded') job.config.formats.forEach(format => { const link = document.createElement('a'); link.href = `/api/jobs/${job.id}/outputs/${format}`; link.textContent = `下载 ${format.toUpperCase()}`; actions.append(link); }); + if (job.status !== 'running') actions.append(actionButton('删除', () => removeJob(job.id))); + detail.append(actions); +} + +function actionButton(label, action) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', event => { event.stopPropagation(); action(); }); return button; } +async function mutate(url, method) { try { await request(url, { method }); await loadJobs(); } catch (error) { alert(error.message); } } +async function retry(id) { try { const job = await request(`/api/jobs/${id}/retry`, { method: 'POST' }); await loadJobs(); selectJob(job.id); } catch (error) { alert(error.message); } } +async function removeJob(id) { if (!confirm('删除该任务及所有输入和输出文件?')) return; try { await request(`/api/jobs/${id}`, { method: 'DELETE' }); state.selected = null; await loadJobs(); } catch (error) { alert(error.message); } } + +$('#add-stage').addEventListener('click', () => addStage({ count: 50 })); +$('#refresh').addEventListener('click', loadJobs); +$('#input-file').addEventListener('change', event => { + const file = event.target.files[0]; if (!file) return; + const image = $('#input-preview'); image.src = URL.createObjectURL(file); image.hidden = false; $('#upload-copy').hidden = true; +}); +$('#job-form').addEventListener('submit', async event => { + event.preventDefault(); const message = $('#form-message'); message.textContent = ''; + const input = $('#input-file').files[0]; if (!input) { message.textContent = '请选择输入图片'; return; } + const formats = $$('input[name="format"]:checked'); if (!formats.length) { message.textContent = '至少选择一种输出格式'; return; } + const data = new FormData(); data.append('input', input); data.append('config', JSON.stringify(collectConfig())); + const button = $('.primary', event.currentTarget); button.disabled = true; + try { const job = await request('/api/jobs', { method: 'POST', body: data }); await loadJobs(); selectJob(job.id); message.textContent = '任务已加入队列'; } + catch (error) { message.textContent = error.message; } + finally { button.disabled = false; } +}); + +addStage(); loadJobs(); setInterval(loadJobs, 5000); diff --git a/internal/webapp/web/index.html b/internal/webapp/web/index.html new file mode 100644 index 00000000..538b6887 --- /dev/null +++ b/internal/webapp/web/index.html @@ -0,0 +1,77 @@ + + + + + + Primitive Studio + + + +
+
GEOMETRIC IMAGE LAB

Primitive Studio

+

上传一张图片,用可组合的几何图形重新绘制它。

+
+ +
+
+
01

创建任务

任务将按提交顺序运行

+
+
+ +
+ + + + +
+
+ +

绘制阶段

+
+ +
+ 输出格式 + + + + +
+ +
+
+ +
+
+
02

任务历史

+

还没有任务

+
+
+
03

结果

+

选择一个任务查看实时进度和结果。

+
+
+
+ + + + + diff --git a/internal/webapp/web/style.css b/internal/webapp/web/style.css new file mode 100644 index 00000000..27c8719d --- /dev/null +++ b/internal/webapp/web/style.css @@ -0,0 +1,4 @@ +:root{--ink:#161714;--muted:#6d7068;--paper:#f4f1e8;--card:#fffdf7;--line:#d6d1c5;--accent:#ff5c35;--green:#1b7f5c;--blue:#315cdd;--shadow:0 14px 40px rgba(36,31,20,.08)} +*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"PingFang SC",sans-serif}.hero{max-width:1280px;margin:auto;padding:54px 28px 28px;display:flex;justify-content:space-between;align-items:end;border-bottom:1px solid var(--ink)}.hero h1{font:700 clamp(42px,7vw,88px)/.9 Georgia,serif;margin:8px 0}.hero p{max-width:330px;color:var(--muted);margin:0 0 8px}.eyebrow{letter-spacing:.18em;font-size:11px;font-weight:800;color:var(--accent)}main{max-width:1280px;margin:auto;padding:28px}.panel{background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow)}.create-panel{padding:28px}.section-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.section-title>div{display:flex;align-items:center;gap:12px}.section-title span{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--ink);border-radius:50%;font-size:11px}.section-title h2{font:700 25px Georgia,serif;margin:0}.section-title p{color:var(--muted);margin:0}.upload-grid{display:grid;grid-template-columns:minmax(260px,.8fr) 1.2fr;gap:22px}.dropzone{min-height:245px;border:1.5px dashed #a29c8e;display:grid;place-items:center;position:relative;overflow:hidden;cursor:pointer;background:#eeeadf;text-align:center}.dropzone input{position:absolute;opacity:0;pointer-events:none}.dropzone img{width:100%;height:100%;position:absolute;object-fit:contain;background:#ddd8cb}.dropzone span{display:grid;gap:6px}.dropzone b{font-size:20px}.dropzone small,label small{color:var(--muted)}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}label{font-weight:700;font-size:13px}input,select{width:100%;margin-top:6px;border:1px solid var(--line);border-radius:0;background:#fff;padding:11px 12px;font:inherit;color:inherit}input:focus,select:focus{outline:2px solid color-mix(in srgb,var(--accent),transparent 55%);border-color:var(--accent)}.stages-heading{display:flex;justify-content:space-between;align-items:center;margin-top:30px}.stages-heading h3{font:700 18px Georgia,serif}.secondary,.primary,.icon-button,.stage-actions button,.job-actions button{border:1px solid var(--ink);background:transparent;padding:9px 13px;font-weight:800;cursor:pointer}.primary{background:var(--ink);color:white;padding:13px 22px}.primary span{color:var(--accent);margin-left:12px}.stages{border-top:1px solid var(--line)}.stage-row{display:grid;grid-template-columns:36px 1.7fr repeat(3,1fr) auto;gap:12px;align-items:end;padding:14px 0;border-bottom:1px solid var(--line)}.stage-index{align-self:center;font:700 20px Georgia,serif;color:var(--accent)}.stage-actions{display:flex;gap:4px}.stage-actions button{padding:9px 11px}.formats{display:flex;gap:22px;border:0;padding:22px 0;margin:0}.formats legend{font-weight:800;padding:18px 0 0}.formats label{display:flex;gap:7px;align-items:center}.formats input{width:auto;margin:0}.form-footer{border-top:1px solid var(--line);padding-top:20px;display:flex;justify-content:space-between;align-items:center}.form-footer p{color:#b52c18;margin:0}.workspace{display:grid;grid-template-columns:minmax(310px,.75fr) 1.25fr;gap:22px;margin-top:22px}.jobs-panel,.detail-panel{padding:24px;min-height:480px}.icon-button{border-radius:50%;padding:5px 10px;font-size:20px}.job-card{border-top:1px solid var(--line);padding:16px 4px;cursor:pointer}.job-card:hover,.job-card.selected{background:#f3efe4}.job-card-top{display:flex;justify-content:space-between;gap:12px}.job-name{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{font-size:10px;text-transform:uppercase;letter-spacing:.08em;border:1px solid currentColor;padding:3px 6px}.badge.running{color:var(--blue)}.badge.succeeded{color:var(--green)}.badge.failed,.badge.interrupted{color:#b52c18}.badge.queued{color:#8a6500}.progress{height:4px;background:#ded9cd;margin-top:12px}.progress i{display:block;height:100%;background:var(--accent)}.job-meta{color:var(--muted);font-size:12px;margin-top:8px;display:flex;justify-content:space-between}.detail-preview{aspect-ratio:1/1;display:grid;place-items:center;background:#eeeadf;border:1px solid var(--line);overflow:hidden}.detail-preview img{max-width:100%;max-height:100%}.detail h3{font:700 28px Georgia,serif;margin:18px 0 4px;overflow-wrap:anywhere}.detail-summary{color:var(--muted)}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);margin:18px 0}.detail-grid div{padding:12px;border-right:1px solid var(--line)}.detail-grid div:last-child{border:0}.detail-grid b,.detail-grid span{display:block}.detail-grid span{color:var(--muted);font-size:11px;text-transform:uppercase}.job-actions{display:flex;flex-wrap:wrap;gap:8px}.job-actions a,.job-actions button{border:1px solid var(--ink);padding:9px 13px;color:inherit;text-decoration:none;background:transparent;font-weight:800;cursor:pointer}.job-actions a{background:var(--ink);color:#fff}.error-box{background:#ffe3da;color:#8a2413;padding:12px}.empty,.empty-detail{color:var(--muted)} +@media(max-width:850px){.hero{display:block}.hero p{margin-top:18px}.upload-grid,.workspace{grid-template-columns:1fr}.stage-row{grid-template-columns:32px 1fr 1fr}.stage-actions{grid-column:2/-1}.formats{flex-wrap:wrap}.detail-panel{min-height:0}} +@media(max-width:520px){main{padding:14px}.hero{padding:34px 16px 20px}.create-panel,.jobs-panel,.detail-panel{padding:16px}.settings-grid{grid-template-columns:1fr}.stage-row{grid-template-columns:28px 1fr}.stage-row label{grid-column:2}.detail-grid{grid-template-columns:1fr}.detail-grid div{border-right:0;border-bottom:1px solid var(--line)}} diff --git a/internal/webapp/webapp_test.go b/internal/webapp/webapp_test.go new file mode 100644 index 00000000..9998f7ea --- /dev/null +++ b/internal/webapp/webapp_test.go @@ -0,0 +1,235 @@ +package webapp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/png" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/FiyZou/primitive/internal/generator" +) + +func TestStoreRecoversRunningJob(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + job, err := store.Create(context.Background(), testPNG(t), "input.png", ".png", testConfig([]string{"png"})) + if err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`UPDATE jobs SET status=? WHERE id=?`, StatusRunning, job.ID); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer store.Close() + recovered, err := store.Get(context.Background(), job.ID) + if err != nil { + t.Fatal(err) + } + if recovered.Status != StatusInterrupted { + t.Fatalf("status = %q, want %q", recovered.Status, StatusInterrupted) + } +} + +func TestQueuedJobAPIAndSSE(t *testing.T) { + app, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(app.Handler()) + defer func() { server.Close(); _ = app.Close() }() + + body, contentType := jobRequest(t, testConfig([]string{"png"})) + request, _ := http.NewRequest(http.MethodPost, server.URL+"/api/jobs", bytes.NewReader(body)) + request.Header.Set("Content-Type", contentType) + request.Header.Set("Origin", "https://evil.example") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin status = %d, want 403", response.StatusCode) + } + + job := postJob(t, server.URL, testConfig([]string{"png"})) + response, err = http.Get(server.URL + "/api/jobs/" + job.ID + "/events") + if err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(response.Body) + eventLine, _ := reader.ReadString('\n') + dataLine, _ := reader.ReadString('\n') + response.Body.Close() + if eventLine != "event: job\n" || !strings.HasPrefix(dataLine, "data: ") { + t.Fatalf("unexpected SSE payload: %q %q", eventLine, dataLine) + } + + response = do(t, http.MethodPost, server.URL+"/api/jobs/"+job.ID+"/cancel", nil, "") + if response.StatusCode != http.StatusOK { + t.Fatalf("cancel status = %d", response.StatusCode) + } + response.Body.Close() + response = do(t, http.MethodPost, server.URL+"/api/jobs/"+job.ID+"/retry", nil, "") + if response.StatusCode != http.StatusCreated { + t.Fatalf("retry status = %d", response.StatusCode) + } + var retried Job + decodeJSON(t, response.Body, &retried) + response.Body.Close() + if retried.Status != StatusQueued || retried.ID == job.ID { + t.Fatalf("unexpected retried job: %#v", retried) + } + + response = do(t, http.MethodDelete, server.URL+"/api/jobs/"+job.ID, nil, "") + if response.StatusCode != http.StatusNoContent { + t.Fatalf("delete status = %d", response.StatusCode) + } + response.Body.Close() +} + +func TestRunnerProducesAllOutputs(t *testing.T) { + app, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + app.Start() + server := httptest.NewServer(app.Handler()) + defer func() { server.Close(); _ = app.Close() }() + + formats := []string{"png", "jpg", "svg", "gif"} + job := postJob(t, server.URL, testConfig(formats)) + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + response, err := http.Get(server.URL + "/api/jobs/" + job.ID) + if err != nil { + t.Fatal(err) + } + decodeJSON(t, response.Body, &job) + response.Body.Close() + if job.Status == StatusSucceeded { + break + } + if job.Status == StatusFailed { + t.Fatalf("job failed: %s", job.Error) + } + time.Sleep(50 * time.Millisecond) + } + if job.Status != StatusSucceeded { + t.Fatalf("job status = %q, want succeeded", job.Status) + } + for _, format := range formats { + response, err := http.Get(server.URL + "/api/jobs/" + job.ID + "/outputs/" + format) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusOK || len(data) == 0 { + t.Fatalf("%s output status=%d bytes=%d", format, response.StatusCode, len(data)) + } + if format != "svg" { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + t.Fatalf("decode %s output: %v", format, err) + } + } + } +} + +func testConfig(formats []string) generator.Config { + return generator.Config{ + InputSize: 0, OutputSize: 16, Workers: 1, Formats: formats, + Stages: []generator.Stage{{Count: 1, Mode: generator.ModeTriangle, Alpha: 128}}, + } +} + +func testPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 30), B: 90, A: 255}) + } + } + var buffer bytes.Buffer + if err := png.Encode(&buffer, img); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func jobRequest(t *testing.T, config generator.Config) ([]byte, string) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("input", "tiny.png") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(testPNG(t)); err != nil { + t.Fatal(err) + } + value, _ := json.Marshal(config) + if err := writer.WriteField("config", string(value)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return body.Bytes(), writer.FormDataContentType() +} + +func postJob(t *testing.T, baseURL string, config generator.Config) Job { + t.Helper() + body, contentType := jobRequest(t, config) + response := do(t, http.MethodPost, baseURL+"/api/jobs", bytes.NewReader(body), contentType) + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + data, _ := io.ReadAll(response.Body) + t.Fatalf("create status = %d: %s", response.StatusCode, data) + } + var job Job + decodeJSON(t, response.Body, &job) + return job +} + +func do(t *testing.T, method, url string, body io.Reader, contentType string) *http.Response { + t.Helper() + request, err := http.NewRequest(method, url, body) + if err != nil { + t.Fatal(err) + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func decodeJSON(t *testing.T, reader io.Reader, value any) { + t.Helper() + if err := json.NewDecoder(reader).Decode(value); err != nil { + t.Fatal(err) + } +} diff --git a/main.go b/main.go index 59e33452..9dbdb3c5 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -12,8 +13,8 @@ import ( "strings" "time" - "github.com/fogleman/primitive/primitive" - "github.com/nfnt/resize" + "github.com/FiyZou/primitive/internal/generator" + "github.com/FiyZou/primitive/primitive" ) var ( @@ -106,6 +107,9 @@ func main() { Configs[0].Alpha = Alpha Configs[0].Repeat = Repeat } + if Nth < 1 { + ok = errorMessage("ERROR: nth argument must be > 0") + } for _, config := range Configs { if config.Count < 1 { ok = errorMessage("ERROR: number argument must be > 0") @@ -137,71 +141,38 @@ func main() { primitive.Log(1, "reading %s\n", Input) input, err := primitive.LoadImage(Input) check(err) - - // scale down input image if needed - size := uint(InputSize) - if size > 0 { - input = resize.Thumbnail(size, size, input, resize.Bilinear) - } - - // determine background color - var bg primitive.Color - if Background == "" { - bg = primitive.MakeColor(primitive.AverageImageColor(input)) - } else { - bg = primitive.MakeHexColor(Background) + stages := make([]generator.Stage, 0, len(Configs)) + for _, config := range Configs { + mode, valid := generator.ModeFromShapeType(primitive.ShapeType(config.Mode)) + if !valid { + check(fmt.Errorf("invalid mode: %d", config.Mode)) + } + stages = append(stages, generator.Stage{Count: config.Count, Mode: mode, Alpha: config.Alpha, Repeat: config.Repeat}) } - - // run algorithm - model := primitive.NewModel(input, bg, OutputSize, Workers) - primitive.Log(1, "%d: t=%.3f, score=%.6f\n", 0, 0.0, model.Score) + config := generator.Config{InputSize: InputSize, OutputSize: OutputSize, Background: Background, Workers: Workers, Stages: stages} start := time.Now() - frame := 0 - for j, config := range Configs { - primitive.Log(1, "count=%d, mode=%d, alpha=%d, repeat=%d\n", - config.Count, config.Mode, config.Alpha, config.Repeat) - - for i := 0; i < config.Count; i++ { - frame++ - - // find optimal shape and add it to the model - t := time.Now() - n := model.Step(primitive.ShapeType(config.Mode), config.Alpha, config.Repeat) - nps := primitive.NumberString(float64(n) / time.Since(t).Seconds()) - elapsed := time.Since(start).Seconds() - primitive.Log(1, "%d: t=%.3f, score=%.6f, n=%d, n/s=%s\n", frame, elapsed, model.Score, n, nps) - - // write output image(s) - for _, output := range Outputs { - ext := strings.ToLower(filepath.Ext(output)) - if output == "-" { - ext = ".svg" - } - percent := strings.Contains(output, "%") - saveFrames := percent && ext != ".gif" - saveFrames = saveFrames && frame%Nth == 0 - last := j == len(Configs)-1 && i == config.Count-1 - if saveFrames || last { - path := output - if percent { - path = fmt.Sprintf(output, frame) - } - primitive.Log(1, "writing %s\n", path) - switch ext { - default: - check(fmt.Errorf("unrecognized file extension: %s", ext)) - case ".png": - check(primitive.SavePNG(path, model.Context.Image())) - case ".jpg", ".jpeg": - check(primitive.SaveJPG(path, model.Context.Image(), 95)) - case ".svg": - check(primitive.SaveFile(path, model.SVG())) - case ".gif": - frames := model.Frames(0.001) - check(primitive.SaveGIFImageMagick(path, frames, 50, 250)) - } - } + _, err = generator.Run(context.Background(), input, config, func(p generator.Progress) error { + primitive.Log(1, "%d: t=%.3f, score=%.6f\n", p.Completed, time.Since(start).Seconds(), p.Score) + for _, output := range Outputs { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(output)), ".") + if output == "-" { + ext = "svg" + } + percent := strings.Contains(output, "%") + saveFrame := percent && ext != "gif" && p.Completed%Nth == 0 + if !saveFrame && p.Completed != p.Total { + continue + } + path := output + if percent { + path = fmt.Sprintf(output, p.Completed) + } + primitive.Log(1, "writing %s\n", path) + if err := generator.SaveOutput(path, ext, p.Model); err != nil { + return err } } - } + return nil + }) + check(err) } diff --git a/primitive/raster.go b/primitive/raster.go index a5d620a3..3870732a 100644 --- a/primitive/raster.go +++ b/primitive/raster.go @@ -10,7 +10,7 @@ func fix(x float64) fixed.Int26_6 { } func fixp(x, y float64) fixed.Point26_6 { - return fixed.Point26_6{fix(x), fix(y)} + return fixed.Point26_6{X: fix(x), Y: fix(y)} } type painter struct { diff --git a/primitive/util.go b/primitive/util.go index 83446cdf..1618dc2f 100644 --- a/primitive/util.go +++ b/primitive/util.go @@ -61,7 +61,7 @@ func SaveJPG(path string, im image.Image, quality int) error { return err } defer file.Close() - return jpeg.Encode(file, im, &jpeg.Options{quality}) + return jpeg.Encode(file, im, &jpeg.Options{Quality: quality}) } func SaveGIF(path string, frames []image.Image, delay, lastDelay int) error {