├── .gitignore
├── README.md
├── Tiltfile
├── assets
├── button.gif
├── live-update.gif
└── tilt-up.gif
├── base.dockerfile
├── filters
├── bounding-box
│ ├── Dockerfile
│ ├── Inconsolata-Bold.ttf
│ ├── k8s.yaml
│ └── main.go
├── color
│ ├── Dockerfile
│ ├── k8s.yaml
│ └── main.go
└── glitch
│ ├── Dockerfile
│ ├── k8s.yaml
│ └── main.go
├── frontend
├── .babelrc
├── .dockerignore
├── Dockerfile
├── components
│ ├── Button.js
│ ├── Header.js
│ ├── ImageDisplay.js
│ ├── ImageSelect.js
│ ├── UploadControl.js
│ ├── apply.svg
│ ├── color.js
│ ├── drop.svg
│ ├── mixer.svg
│ ├── pixel-tilt-logo.svg
│ ├── reset.svg
│ ├── scan.svg
│ ├── tilt-imagemark.svg
│ └── tilt-logo.svg
├── k8s.yaml
├── package-lock.json
├── package.json
├── pages
│ ├── _app.js
│ ├── _document.js
│ ├── api
│ │ ├── image
│ │ │ └── [name].js
│ │ ├── list.js
│ │ └── upload.js
│ ├── index.js
│ └── styles.css
└── public
│ ├── baby-bear.png
│ ├── duck.png
│ └── plane.png
├── go.mod
├── go.sum
├── muxer
├── Dockerfile
├── k8s.yaml
└── main.go
├── object-detector
└── k8s.yaml
├── pixeltilt.gif
├── render
└── api
│ └── api.go
└── storage
├── Dockerfile
├── api
└── api.go
├── client
└── client.go
├── k8s.yaml
└── main.go
/.gitignore:
--------------------------------------------------------------------------------
1 | # frontend
2 | node_modules/
3 | .pnp
4 | .pnp.js
5 | .next/
6 |
7 | # testing
8 | coverage/
9 |
10 | # production
11 | build/
12 |
13 | # tilt
14 | tilt_modules/
15 |
16 | # misc
17 | .DS_Store
18 |
19 | .env.local
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 |
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PixelTilt
2 | PixelTilt is a browser-based image editor. It is designed as demo project for [Tilt][tilt] and is comprised of several
3 | microservices deployed via Kubernetes.
4 |
5 | 
6 |
7 | The [`Tiltfile`](https://github.com/tilt-dev/pixeltilt/blob/master/Tiltfile) contains comments and links to relevant
8 | documentation.
9 |
10 | ## Getting Started
11 | If you don't already have Tilt installed, follow the [Tilt install][tilt-install] guide.
12 | > :information_source: The install guide also covers the prerequisites: Docker, `kubectl`, and a local Kubernetes development cluster
13 |
14 | Once you're ready, clone this repo and run `tilt up` from the main `pixeltilt` directory.
15 |
16 | 
17 |
18 | After some time, the resources should all become ready. If a resource fails, click on it to navigate to the logs and
19 | investigate. Once successful, try clicking the endpoint link for the `frontend` resource to start using the PixelTilt
20 | app!
21 |
22 | ## Showcase
23 | ### Live Update
24 | The `glitch` resource is configured to use [live update][live-update].
25 |
26 | Try editing `filters/glitch/main.go` to use one of the commented out formulas instead and see what happens:
27 | 
28 |
29 | ### Custom Buttons
30 | The `storage` resource has a custom [UIButton][uibutton-ext] to make it easy to reset the application state.
31 |
32 | When clicked, a `curl` command accesses a special endpoint on the microservice to flush the data:
33 | 
34 |
35 | [ctlptl]: https://github.com/tilt-dev/ctlptl
36 |
37 | [live-update]: https://docs.tilt.dev/live_update_tutorial.html
38 |
39 | [tilt]: https://github.com/tilt-dev/tilt
40 |
41 | [tilt-install]: https://docs.tilt.dev/install.html
42 |
43 | [uibutton-ext]: https://github.com/tilt-dev/tilt-extensions/tree/master/uibutton
44 |
--------------------------------------------------------------------------------
/Tiltfile:
--------------------------------------------------------------------------------
1 | # version_settings() enforces a minimum Tilt version
2 | # https://docs.tilt.dev/api.html#api.version_settings
3 | version_settings(constraint='>=0.22.1')
4 |
5 | # load() can be used to split your Tiltfile logic across multiple files
6 | # the special ext:// prefix loads the corresponding extension from
7 | # https://github.com/tilt-dev/tilt-extensions instead of a local file
8 | load('ext://restart_process', 'docker_build_with_restart')
9 | load('ext://uibutton', 'cmd_button')
10 |
11 | # k8s_yaml automatically creates resources in Tilt for the entities
12 | # and will inject any images referenced in the Tiltfile when deploying
13 | # https://docs.tilt.dev/api.html#api.k8s_yaml
14 | k8s_yaml([
15 | 'filters/glitch/k8s.yaml',
16 | 'filters/color/k8s.yaml',
17 | 'filters/bounding-box/k8s.yaml',
18 | 'storage/k8s.yaml',
19 | 'muxer/k8s.yaml',
20 | 'object-detector/k8s.yaml',
21 | 'frontend/k8s.yaml',
22 | ])
23 |
24 | # k8s_resource allows customization where necessary such as adding port forwards
25 | # https://docs.tilt.dev/api.html#api.k8s_resource
26 | k8s_resource("frontend", port_forwards="3000", labels=["frontend"])
27 | k8s_resource("storage", port_forwards="8080", labels=["infra"])
28 | k8s_resource("max-object-detector", labels=["infra"], new_name="object-detector")
29 | k8s_resource("glitch", labels=["backend"])
30 | k8s_resource("color", labels=["backend"])
31 | k8s_resource("bounding-box", labels=["backend"])
32 | k8s_resource("muxer", labels=["backend"])
33 |
34 | # cmd_button extension adds custom buttons to a resource to execute tasks on demand
35 | # https://github.com/tilt-dev/tilt-extensions/tree/master/uibutton
36 | cmd_button(
37 | name='flush-storage',
38 | resource='storage',
39 | argv=['curl', '-s', 'http://localhost:8080/flush'],
40 | text='Flush DB',
41 | icon_name='delete'
42 | )
43 |
44 |
45 | # frontend is a next.js app which has built-in support for hot reload
46 | # live_update only syncs changed files to the correct place for it to pick up
47 | # https://docs.tilt.dev/api.html#api.docker_build
48 | # https://docs.tilt.dev/live_update_reference.html
49 | docker_build(
50 | "frontend",
51 | context="./frontend",
52 | live_update=[
53 | sync('./frontend', '/usr/src/app')
54 | ]
55 | )
56 |
57 | # the various go services share a base image to avoid re-downloading the same
58 | # dependencies numerous times - `only` is used to prevent unnecessary rebuilds
59 | # https://docs.tilt.dev/api.html#api.docker_build
60 | docker_build(
61 | "pixeltilt-base",
62 | context=".",
63 | dockerfile="base.dockerfile",
64 | only=['go.mod', 'go.sum']
65 | )
66 |
67 | # docker_build_with_restart automatically restarts the process defined by
68 | # `entrypoint` argument after completing the live_update (which syncs .go
69 | # source files and recompiles inside the container)
70 | # https://github.com/tilt-dev/tilt-extensions/tree/master/restart_process
71 | # https://docs.tilt.dev/live_update_reference.html
72 | docker_build_with_restart(
73 | "glitch",
74 | context=".",
75 | dockerfile="filters/glitch/Dockerfile",
76 | only=['filters/glitch', 'render/api'],
77 | entrypoint='/usr/local/bin/glitch',
78 | live_update=[
79 | sync('filters/glitch', '/app/glitch'),
80 | sync('render/api', '/app/render/api'),
81 | run('go build -mod=vendor -o /usr/local/bin/glitch ./glitch')
82 | ]
83 | )
84 |
85 | # for the remainder of the services, plain docker_build is used - these
86 | # services are changed less frequently, so live_update is less important
87 | # any of them can be adapted to use live_update by using "glitch" as an
88 | # example above!
89 | docker_build(
90 | "muxer",
91 | context=".",
92 | dockerfile="muxer/Dockerfile",
93 | only=['muxer', 'render/api', 'storage/api', 'storage/client']
94 | )
95 |
96 | docker_build(
97 | "color",
98 | context=".",
99 | dockerfile="filters/color/Dockerfile",
100 | only=['filters/color', 'render/api']
101 | )
102 |
103 | docker_build(
104 | "bounding-box",
105 | context=".",
106 | dockerfile="filters/bounding-box/Dockerfile",
107 | only=['filters/bounding-box', 'render/api']
108 | )
109 |
110 | docker_build(
111 | "storage",
112 | context=".",
113 | dockerfile="storage/Dockerfile",
114 | only=['storage'],
115 | entrypoint='/usr/local/bin/storage'
116 | )
117 |
--------------------------------------------------------------------------------
/assets/button.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/button.gif
--------------------------------------------------------------------------------
/assets/live-update.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/live-update.gif
--------------------------------------------------------------------------------
/assets/tilt-up.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/tilt-up.gif
--------------------------------------------------------------------------------
/base.dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.16-alpine
2 |
3 | WORKDIR /app
4 |
5 | ENV GOMODCACHE=/cache/gomod
6 | ENV GOCACHE=/cache/gobuild
7 |
8 | COPY go.mod go.sum ./
9 | RUN --mount=type=cache,target=/cache/gomod \
10 | go mod download
11 |
--------------------------------------------------------------------------------
/filters/bounding-box/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pixeltilt-base
2 |
3 | COPY ../../render/api render/api/
4 | COPY filters/bounding-box/ rectangler/
5 |
6 | RUN --mount=type=cache,target=/cache/gomod \
7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \
8 | go mod vendor && \
9 | go build -mod=vendor -o /usr/local/bin/rectangler ./rectangler
10 |
11 | CMD ["/usr/local/bin/rectangler"]
12 |
--------------------------------------------------------------------------------
/filters/bounding-box/Inconsolata-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/filters/bounding-box/Inconsolata-Bold.ttf
--------------------------------------------------------------------------------
/filters/bounding-box/k8s.yaml:
--------------------------------------------------------------------------------
1 |
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: bounding-box
6 | labels:
7 | app: bounding-box
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: bounding-box
12 | template:
13 | metadata:
14 | labels:
15 | app: bounding-box
16 | spec:
17 | containers:
18 | - name: bounding-box
19 | image: bounding-box
20 | ports:
21 | - containerPort: 8080
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: bounding-box
27 | labels:
28 | app: bounding-box
29 | spec:
30 | ports:
31 | - port: 8080
32 | targetPort: 8080
33 | protocol: TCP
34 | selector:
35 | app: bounding-box
36 |
--------------------------------------------------------------------------------
/filters/bounding-box/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "image"
8 | "image/draw"
9 | "image/png"
10 | "io/ioutil"
11 | "log"
12 | "mime/multipart"
13 | "net/http"
14 | "os"
15 | "strings"
16 |
17 | "github.com/fogleman/gg"
18 | "github.com/golang/freetype/truetype"
19 | "github.com/pkg/errors"
20 | "github.com/tilt-dev/pixeltilt/render/api"
21 | )
22 |
23 | type data struct {
24 | Status string `json:"status"`
25 | Predictions []struct {
26 | LabelID string `json:"label_id"`
27 | Label string `json:"label"`
28 | Probability float64 `json:"probability"`
29 | DetectionBox []float64 `json:"detection_box"`
30 | } `json:"predictions"`
31 | }
32 |
33 | func main() {
34 | fmt.Println("Rectangler running!")
35 | port := "8080"
36 | if len(os.Args) > 1 {
37 | port = os.Args[1]
38 | }
39 |
40 | HandleWithNoSubpath("/", api.HttpRenderHandler(render))
41 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
42 | }
43 |
44 | func HandleWithNoSubpath(path string, f func(http.ResponseWriter, *http.Request)) {
45 | handler := func(w http.ResponseWriter, req *http.Request) {
46 | if req.URL.Path != path {
47 | http.NotFound(w, req)
48 | return
49 | }
50 | f(w, req)
51 | }
52 |
53 | http.HandleFunc(path, handler)
54 | }
55 |
56 | func render(req api.RenderRequest) (api.RenderReply, error) {
57 | // Run detection on original image
58 | detected, err := sendPostRequest("http://max-object-detector:5000/model/predict?threshold=0.7", "image", req.OriginalImage)
59 | if err != nil {
60 | return api.RenderReply{}, errors.Wrap(err, "max-object-detector")
61 | }
62 |
63 | data := data{}
64 | err = json.Unmarshal(detected, &data)
65 | if err != nil {
66 | return api.RenderReply{}, errors.Wrap(err, "max-object-detector")
67 | }
68 |
69 | input, err := png.Decode(bytes.NewReader(req.Image))
70 | if err != nil {
71 | return api.RenderReply{}, errors.Wrap(err, "decoding image")
72 | }
73 |
74 | overlayImg, err := rectangler(data, input.Bounds().Size())
75 | if err != nil {
76 | return api.RenderReply{}, errors.Wrap(err, "rectangling")
77 | }
78 |
79 | originalImage, err := png.Decode(bytes.NewReader(req.Image))
80 | if err != nil {
81 | return api.RenderReply{}, errors.Wrap(err, "decoding image")
82 | }
83 |
84 | merged := merge(originalImage, overlayImg)
85 | var buf bytes.Buffer
86 | err = png.Encode(&buf, merged)
87 | if err != nil {
88 | return api.RenderReply{}, errors.Wrap(err, "encoding image")
89 | }
90 |
91 | return api.RenderReply{Image: buf.Bytes()}, nil
92 | }
93 |
94 | // type data struct {
95 | // Status string `json:"status"e`
96 | // Predictions []struct {
97 | // LabelID string `json:"label_id"`
98 | // Label string `json:"label"`
99 | // Probability int `json:"probability"`
100 | // DetectionBox []int `json:"detection_box"`
101 | // } `json:"predictions"`
102 | // }
103 |
104 | // [ymin, xmin, ymax, xmax]
105 | func rectangler(data data, pt image.Point) (*image.RGBA, error) {
106 | pic := gg.NewContext(pt.X, pt.Y)
107 | pic.SetRGBA(0, 0, 0, 0)
108 | pic.Clear()
109 | w, h := float64(pt.X), float64(pt.Y)
110 |
111 | fontfile, err := ioutil.ReadFile("rectangler/Inconsolata-Bold.ttf")
112 | if err != nil {
113 | return nil, err
114 | }
115 | font, err := truetype.Parse(fontfile)
116 | if err != nil {
117 | return nil, err
118 | }
119 | fontSize := (w + h) / 70
120 | face := truetype.NewFace(font, &truetype.Options{Size: fontSize})
121 | pic.SetFontFace(face)
122 |
123 | for i := 0; i < len(data.Predictions); i++ {
124 | values := data.Predictions[i].DetectionBox
125 | ymin, xmin, ymax, xmax := values[0], values[1], values[2], values[3]
126 | pic.SetRGBA(1, 1, 1, 1)
127 | pic.SetLineWidth((w + h) / 500)
128 |
129 | pic.DrawRectangle(xmin*w, ymin*h, xmax*w-xmin*w, ymax*h-ymin*h)
130 | pic.Stroke()
131 |
132 | object := fmt.Sprintf("OBJECT: %s", strings.ToUpper(data.Predictions[i].Label))
133 | pic.DrawString(object, xmin*w, ymin*h-fontSize/2-fontSize*1.2)
134 | probability := fmt.Sprintf("PROBABILITY: %f", data.Predictions[i].Probability)
135 | pic.DrawString(probability, xmin*w, ymin*h-fontSize/2)
136 | }
137 |
138 | return pic.Image().(*image.RGBA), nil
139 | }
140 |
141 | func sendPostRequest(url string, name string, image []byte) ([]byte, error) {
142 | body := &bytes.Buffer{}
143 | writer := multipart.NewWriter(body)
144 | part, err := writer.CreateFormFile("image", name)
145 | if err != nil {
146 | return nil, err
147 | }
148 |
149 | _, err = part.Write(image)
150 | if err != nil {
151 | return nil, err
152 | }
153 | err = writer.Close()
154 | if err != nil {
155 | return nil, err
156 | }
157 |
158 | request, err := http.NewRequest("POST", url, body)
159 | if err != nil {
160 | return nil, err
161 | }
162 |
163 | request.Header.Add("Content-Type", writer.FormDataContentType())
164 | request.Header.Add("accept", "application/json")
165 | client := &http.Client{}
166 |
167 | response, err := client.Do(request)
168 | if err != nil {
169 | return nil, err
170 | }
171 | defer response.Body.Close()
172 |
173 | content, err := ioutil.ReadAll(response.Body)
174 | if err != nil {
175 | return nil, err
176 | }
177 |
178 | return content, nil
179 | }
180 |
181 | func merge(img1, img2 image.Image) *image.RGBA {
182 | img3 := image.NewRGBA(img1.Bounds())
183 | draw.Draw(img3, img3.Bounds(), img1, image.ZP, draw.Src)
184 | draw.Draw(img3, img3.Bounds(), img2, image.ZP, draw.Over)
185 | return img3
186 | }
187 |
--------------------------------------------------------------------------------
/filters/color/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pixeltilt-base
2 |
3 | COPY ../../render/api render/api/
4 | COPY filters/color/ red/
5 |
6 | RUN --mount=type=cache,target=/cache/gomod \
7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \
8 | go mod vendor && \
9 | go build -mod=vendor -o /usr/local/bin/red ./red
10 |
11 | CMD ["/usr/local/bin/red"]
12 |
--------------------------------------------------------------------------------
/filters/color/k8s.yaml:
--------------------------------------------------------------------------------
1 |
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: color
6 | labels:
7 | app: color
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: color
12 | template:
13 | metadata:
14 | labels:
15 | app: color
16 | spec:
17 | containers:
18 | - name: color
19 | image: color
20 | ports:
21 | - containerPort: 8080
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: color
27 | labels:
28 | app: color
29 | spec:
30 | ports:
31 | - port: 8080
32 | targetPort: 8080
33 | protocol: TCP
34 | selector:
35 | app: color
36 |
--------------------------------------------------------------------------------
/filters/color/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "image"
7 | "image/color"
8 | "image/png"
9 | "log"
10 | "net/http"
11 | "os"
12 |
13 | "github.com/tilt-dev/pixeltilt/render/api"
14 | )
15 |
16 | func main() {
17 | fmt.Println("Red running!")
18 | port := "8080"
19 | if len(os.Args) > 1 {
20 | port = os.Args[1]
21 | }
22 |
23 | http.HandleFunc("/", api.HttpRenderHandler(render))
24 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
25 | }
26 |
27 | func render(req api.RenderRequest) (api.RenderReply, error) {
28 | pic, err := png.Decode(bytes.NewReader(req.Image))
29 | if err != nil {
30 | return api.RenderReply{}, err
31 | }
32 | fmt.Println("red decoded ok")
33 |
34 | newImg := red(pic)
35 | var buf bytes.Buffer
36 | err = png.Encode(&buf, newImg)
37 | if err != nil {
38 | return api.RenderReply{}, err
39 | }
40 | resp := api.RenderReply{
41 | Image: buf.Bytes(),
42 | }
43 |
44 | return resp, nil
45 | }
46 |
47 | func red(pic image.Image) *image.RGBA {
48 | picSize := pic.Bounds().Size()
49 | newPic := image.NewRGBA(image.Rect(0, 0, picSize.X, picSize.Y))
50 |
51 | for x := 0; x < picSize.X; x++ {
52 | for y := 0; y < picSize.Y; y++ {
53 | pixel := color.RGBAModel.Convert(pic.At(x, y)).(color.RGBA)
54 | new := color.RGBA{
55 | // R: uint8(float64(pixel.R) * 0.8),
56 | // G: 0,
57 | R: 0,
58 | G: uint8(float64(pixel.G) * 0.8),
59 | B: 0,
60 | A: pixel.A,
61 | }
62 | newPic.Set(x, y, new)
63 | }
64 | }
65 |
66 | return newPic
67 | }
68 |
--------------------------------------------------------------------------------
/filters/glitch/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pixeltilt-base
2 |
3 | COPY ../../render/api render/api/
4 | COPY filters/glitch/ glitch/
5 |
6 | RUN --mount=type=cache,target=/cache/gomod \
7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \
8 | go mod vendor && \
9 | go build -mod=vendor -o /usr/local/bin/glitch ./glitch
10 |
11 | CMD ["/usr/local/bin/glitch"]
12 |
--------------------------------------------------------------------------------
/filters/glitch/k8s.yaml:
--------------------------------------------------------------------------------
1 |
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: glitch
6 | labels:
7 | app: glitch
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: glitch
12 | template:
13 | metadata:
14 | labels:
15 | app: glitch
16 | spec:
17 | containers:
18 | - name: glitch
19 | image: glitch
20 | ports:
21 | - containerPort: 8080
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: glitch
27 | labels:
28 | app: glitch
29 | spec:
30 | ports:
31 | - port: 8080
32 | targetPort: 8080
33 | protocol: TCP
34 | selector:
35 | app: glitch
36 |
--------------------------------------------------------------------------------
/filters/glitch/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | _ "image/jpeg"
7 | "image/png"
8 | _ "image/png"
9 | "log"
10 | "net/http"
11 | "os"
12 |
13 | "github.com/pkg/errors"
14 | "github.com/sug0/go-glitch"
15 |
16 | "github.com/tilt-dev/pixeltilt/render/api"
17 | )
18 |
19 | // glitch formulas from: https://github.com/sug0/go-glitch/blob/master/res/cool.txt
20 |
21 | // const glitchExprStr = "128 & (c - ((c - 150 + s) > 5 < s))"
22 | // const glitchExprStr = "(c & (c ^ 55)) + 25"
23 | // const glitchExprStr = "r | ((255 - (Y > 2) : ((r | c) < 2)) - 140)"
24 | // const glitchExprStr = "(Y | (c > 1)) ^ 128"
25 | // const glitchExprStr = "e ^ c - (e - 55)"
26 | // const glitchExprStr = "86 ^ ((R&c) > ((G&c) - ((G&c) ^ ((G&c) / (175 : (x < ((B&c) + ((G&c) + r))))))))"
27 | // const glitchExprStr = "(Y - ((G & 55)|(R - 25))) ^ 25"
28 | // const glitchExprStr = "Y : (146 - (e | (15 ? 185)))"
29 | // const glitchExprStr = "Y & (206 / (e & (Y / r)))"
30 | // const glitchExprStr = "179 ^ (Y % (x | (s : (209 : (Y % (G - (r # r)))))))"
31 | // const glitchExprStr = "36 - (67 | (Y - (G | R)))"
32 | // const glitchExprStr = "128 & (c - (s - 255) + s) : (s ^ (c ^ 255)) + 25"
33 | // const glitchExprStr = "((Y/s) + c < 1) + (x*x*y) % (c ^ ((y*x - y*y) < (y > 5)))"
34 | // const glitchExprStr = "(c&x-y)/(s | x*y) - ((y+c)/x < 2)"
35 | // const glitchExprStr = "(c & (s ^ 55)) + (25 > s)"
36 | // const glitchExprStr = "128 ^ ((r ^ 15) | (c - (s - (r * 255))))"
37 | // const glitchExprStr = "(r | c) < 3"
38 | // const glitchExprStr = "(r - Y) | (((r | ((c | Y) > 2)) < 2) + (Y % 255))"
39 | // const glitchExprStr = "((Y | ((((108 - Y) * s) > c + Y + s) ^ 5 + 25)) * (r > 5)) : ((c < 1) | Y)"
40 | // const glitchExprStr = "(s & c) | (((Y + (r - 55)) ^ s) > 10) < 10 - ((128 - c) | Y)"
41 | // const glitchExprStr = "(s & c) | (((Y + (r - 55)) ^ s) > 15) < 10 - ((16 - Y) | c)"
42 | // const glitchExprStr = "(r - ((Y % 15) : (r < 5)) > 10) | (s - c)"
43 | // const glitchExprStr = "c|((e - s + x - y) / N) - (R&c)|(B&c) + ((G&c)*(G&c)*(G&c))/(x*x*x) - (s & (R&c))"
44 | // const glitchExprStr = "((x*x*x % y) + (c - s) + r) > 1"
45 | // const glitchExprStr = "((x - y) # (y - x) ? (R&c)|(G&c)) - ((255 - (80 ? c)) & c)"
46 | // const glitchExprStr = "((128 ? x) ^ (128 ? y)) : ((y - x) # (x - y) ? (Y-c)) - ((255 - (80 ? s)) & y - (R&c)|(G&c))"
47 | // const glitchExprStr = "((s - x) # (e - s)) ? ((128 - x) ^ (y - c)) ? (128 - Y)"
48 | // const glitchExprStr = "c & (((230 - (130 ? Y)) : (s - ((G&c)|(R&c))) + s) - s) + s"
49 | // const glitchExprStr = "y ^ ((G&c) + ((B&c) | ((B&c) * (y | (166 ? ((G&c) % s))))))"
50 | // const glitchExprStr = "33 + (e - (s > ((R&c) : (Y + (x ? (204 < 243))))))"
51 | // const glitchExprStr = "(e | R) - ((c & G) - (Y & G))"
52 | // const glitchExprStr = "b ^ (233 < (B > (r # b)))"
53 | // const glitchExprStr = "Y ^ (106 & (Y ? (r < G)))"
54 | // const glitchExprStr = "182 + (x + (s - (y % (15 & (r ? (e + (s : (76 - (y # (r * (r ? (r | (y % (r ? (195 ? (R - (123 > (b : N)))))))))))))))")))
55 | // const glitchExprStr = "b ^ (164 - (b < (G # (b ^ (e % r)))))"
56 | // const glitchExprStr = "c | (s < (c & (r ? (B | (e - (Y < (Y ^ Y)))))))"
57 |
58 | const glitchExprStr = "b ^ (r | (s : (x # B)))"
59 |
60 | // const glitchExprStr = "G / (b / (N > (110 > s)))"
61 | // const glitchExprStr = "29 ? (Y & (c ^ (s > c)))"
62 | // const glitchExprStr = "((c|Y) < 3) @ ((H-L+10)|(((c < 1) @ ((s/x) * y)) ^ (c - L) | (Y - L)))"
63 | // const glitchExprStr = "(c<(s%4)>1)@(L+25)-25"
64 | // const glitchExprStr = "(c&(R-Y)&25)^(Y|((Y:h)-25))@(Y-s)"
65 | // const glitchExprStr = "255 ^ ((H ^ L) - s) ? ((c & (R @ 246)) | (c & (G @ 155)) | (c & (B @ 255)))"
66 | // const glitchExprStr = "e@((H-((c < 2) - (N@r)%128))+(L&R)-(H-G))-((e&r)/16)"
67 | // const glitchExprStr = "((x*x*x*y*y*y) < s) + (c > 4)"
68 | // const glitchExprStr = "c & (Y - x*y)"
69 | // const glitchExprStr = "c * (x ^ 5)"
70 | // const glitchExprStr = "((y*s - y*s) & (Y | c)) ^ (x|c)"
71 | // const glitchExprStr = "((y*s - y*s < s) & (Y | c)) ^ (x|c) > s"
72 | // const glitchExprStr = "(r - y) | ((55 - x*3) ^ 25) : (((y*s - y*s < s) & (Y | c)) ^ ((255-y)+x|c) > s)"
73 | // const glitchExprStr = "128 + ((r > 2 < 1) : ((x|y) ^ 55)) % 50 - c + y"
74 | // const glitchExprStr = "c & ((x+y*y*y+r) : (s + c % 15) - x*x)"
75 | // const glitchExprStr = "c ^ ((x ? Y) ? (255 - e))"
76 | // const glitchExprStr = "((Y - x) ? (c - s)) ^ (0 - (x + y) : (0 - (y - x)))"
77 | // const glitchExprStr = "((s + (R&c)) - c) & (0 - (x ? (0 - (x - x*y) : y)) - (1 : (y ^ x))) - (c + s)"
78 | // const glitchExprStr = "(c @ (s & G)) ^ (c - L)"
79 | // const glitchExprStr = "L ^ H > H < L % R"
80 | // const glitchExprStr = "(c ^ (L ^ H)) % (R > c) : (( L ^ H < R > c) % (5 ^ R))"
81 | // const glitchExprStr = "(Y - c)|(r - s/2) + (61 & (R - (r % Y)))"
82 | // const glitchExprStr = "(c-(s>2))/16*((Y-H)@128)"
83 | // const glitchExprStr = "((y/(x*x) ^ s) + x*s) > 2 + c"
84 | // const glitchExprStr = "Y < (s # (e * (y & (x : (x & (s % (r + (s ? c))))))))"
85 | // const glitchExprStr = "120 : (Y & (N | (s & (B & (e # (Y + (Y : R)))))))"
86 | // const glitchExprStr = "Y | (34 % (194 < (e < G)))"
87 | // const glitchExprStr = "r + (61 & (R - (r % e)))"
88 | // const glitchExprStr = "Y ^ (s & (60 > (y < (239 ? (c / b)))))"
89 | // const glitchExprStr = "#((1 - y : x) ^ (1 - y & x)) & (0 - (x*x*x % y))"
90 | // const glitchExprStr = "(s ^ (1 - x : y)) & ((((e|(G&c)) - r)|N < 2) > 1)"
91 | // const glitchExprStr = "((1 - y : x) | (1 - x : y)) - c"
92 | // const glitchExprStr = "(R&c)|(G&c) % (((1 - y : x) ^ (1 - x : y)) - c)"
93 | // const glitchExprStr = "100 + ((N & 25) ^ (1 - x : y)) + (75 & Y)"
94 | // const glitchExprStr = "((G&c) ^ (e - (R&c))) % ((N & 25) ^ (e - x : y)) + (75 & Y)"
95 | // const glitchExprStr = "(Y : c) ^ ((c + 50) & (0 - (y ? (0 - x : y)) & (y : x)))"
96 | // const glitchExprStr = "(255 - (140 ? c)) & c"
97 | // const glitchExprStr = "(H-L)|b"
98 | // const glitchExprStr = "L ^ H"
99 | // const glitchExprStr = "(c ^ (L ^ H)) % (R > c) : ( L ^ H < R > c)"
100 | // const glitchExprStr = "(r > (H ^ L) % b) : (H % L)"
101 | // const glitchExprStr = "((H + (s/L) - L) & R) | ((G / (b / (N > (110 > s)))) : R)"
102 | // const glitchExprStr = "b ^ (r | (s : (x # B)))"
103 |
104 | var glitchExpr *glitch.Expression
105 |
106 | func main() {
107 | fmt.Println("Glitch running!")
108 | port := "8080"
109 | if len(os.Args) > 1 {
110 | port = os.Args[1]
111 | }
112 |
113 | var err error
114 | glitchExpr, err = glitch.CompileExpression(glitchExprStr)
115 | if err != nil {
116 | log.Fatal(err)
117 | }
118 |
119 | HandleWithNoSubpath("/", api.HttpRenderHandler(render))
120 | http.HandleFunc("/set_expr", setExpr)
121 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
122 | }
123 |
124 | func HandleWithNoSubpath(path string, f func(http.ResponseWriter, *http.Request)) {
125 | handler := func(w http.ResponseWriter, req *http.Request) {
126 | if req.URL.Path != path {
127 | http.NotFound(w, req)
128 | return
129 | }
130 | f(w, req)
131 | }
132 |
133 | http.HandleFunc(path, handler)
134 | }
135 |
136 | // just a dumb form to let people play around with different expressions without restarting
137 | func setExpr(w http.ResponseWriter, r *http.Request) {
138 | w.Header().Set("Content-Type", "text/html")
139 | _, err := w.Write([]byte(`
140 |
144 | `))
145 | if err != nil {
146 | log.Printf("error writing response: %v\n", err)
147 | return
148 | }
149 |
150 | err = r.ParseForm()
151 | if err != nil {
152 | http.Error(w, fmt.Sprintf("error parsing request: %v", err), http.StatusBadRequest)
153 | return
154 | }
155 |
156 | expr := r.PostForm.Get("expr")
157 | if expr != "" {
158 | g, err := glitch.CompileExpression(expr)
159 | if err != nil {
160 | _, err := w.Write([]byte(fmt.Sprintf("
Invalid expression: %s
", err.Error())))
161 | if err != nil {
162 | log.Printf("error writing response: %v\n", err)
163 | return
164 | }
165 | }
166 | glitchExpr = g
167 | log.Printf("changed expression to %s\n", expr)
168 | _, err = w.Write([]byte(fmt.Sprintf("
Expression set to %s
", expr)))
169 | if err != nil {
170 | log.Printf("error writing response: %v\n", err)
171 | return
172 | }
173 | }
174 | }
175 |
176 | func render(req api.RenderRequest) (api.RenderReply, error) {
177 | input, err := png.Decode(bytes.NewReader(req.Image))
178 | if err != nil {
179 | // return api.RenderReply{}, errors.Wrap(err, "decoding image")
180 | fmt.Println("Glitch went boom!")
181 | os.Exit(1)
182 | }
183 |
184 | output, err := glitchExpr.JumblePixels(input)
185 | if err != nil {
186 | return api.RenderReply{}, errors.Wrap(err, "glitching image")
187 | }
188 |
189 | var buf bytes.Buffer
190 | err = png.Encode(&buf, output)
191 | if err != nil {
192 | return api.RenderReply{}, errors.Wrap(err, "encoding image")
193 | }
194 |
195 | return api.RenderReply{Image: buf.Bytes()}, nil
196 | }
197 |
--------------------------------------------------------------------------------
/frontend/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": [
6 | "inline-react-svg",
7 | [
8 | "styled-components",
9 | {
10 | "ssr": true,
11 | "displayName": true,
12 | "preprocess": false
13 | }
14 | ]
15 | ]
16 | }
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | build
2 | .next
3 | node_modules
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine3.14
2 |
3 | WORKDIR /usr/src/app
4 |
5 | ADD package.json package-lock.json ./
6 | RUN npm install
7 |
8 | ADD . .
9 |
10 | EXPOSE 3000
11 | CMD ["npm", "run", "dev"]
12 |
--------------------------------------------------------------------------------
/frontend/components/Button.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import color from "./color";
3 |
4 | let ButtonRoot = styled.button`
5 | display: flex;
6 | border: 1px solid ${color.red};
7 | color: ${color.red};
8 | box-sizing: border-box;
9 | border-radius: 4px;
10 | padding: 0 8px;
11 | margin: 0 8px 0 0;
12 | align-items: center;
13 | justify-content: space-between;
14 | height: 30px;
15 | background-color: inherit;
16 | font-size: 10px;
17 | line-height: 14px;
18 | font-family: inherit;
19 | cursor: pointer;
20 | transition: background-color 300ms ease, color 300ms ease;
21 | box-sizing: content-box;
22 |
23 | &.is-toggle:hover {
24 | padding: 1px 9px;
25 | margin-left: -1px;
26 | margin-right: 7px !important;
27 | }
28 |
29 | &[disabled],
30 | &.is-green[disabled],
31 | &.is-yellow[disabled] {
32 | pointer-events: none;
33 | border-color: ${color.gray};
34 | color: ${color.gray};
35 | }
36 | &[disabled] path,
37 | &.is-green[disabled] path,
38 | &.is-yellow[disabled] path {
39 | fill: ${color.gray};
40 | }
41 |
42 | & > svg {
43 | width: 14px;
44 | height: 14px;
45 | margin-right: 7px;
46 | }
47 |
48 | & path {
49 | transition: fill 300ms ease;
50 | fill: ${color.red};
51 | }
52 |
53 | &:not(.is-toggle):hover {
54 | background-color: ${color.red};
55 | color: ${color.grayDark};
56 | }
57 | &:not(.is-toggle):hover path {
58 | fill: ${color.grayDark};
59 | }
60 |
61 | &.is-yellow {
62 | border-color: ${color.yellow};
63 | color: ${color.yellow};
64 | }
65 | &.is-yellow path {
66 | fill: ${color.yellow};
67 | }
68 | &.is-yellow:hover {
69 | color: ${color.grayDark};
70 | background-color: ${color.yellow};
71 | }
72 | &.is-yellow:hover path {
73 | fill: ${color.grayDark};
74 | }
75 |
76 | &.is-green {
77 | border-color: ${color.green};
78 | color: ${color.green};
79 | }
80 | &.is-green path {
81 | fill: ${color.green};
82 | }
83 | &.is-green:hover {
84 | color: ${color.grayDark};
85 | background-color: ${color.green};
86 | }
87 | &.is-green:hover path {
88 | fill: ${color.grayDark};
89 | }
90 |
91 | &.is-selected {
92 | background-color: ${color.red};
93 | color: ${color.grayDark};
94 | }
95 | &.is-selected.is-yellow {
96 | background-color: ${color.yellow};
97 | }
98 | &.is-selected.is-green {
99 | background-color: ${color.green};
100 | }
101 | &.is-selected path {
102 | fill: ${color.grayDark};
103 | }
104 | `;
105 |
106 | function Button(props) {
107 | let onClick = props.onClick;
108 | let name = props.name;
109 | let extraClasses = [];
110 | if (props.selected) {
111 | extraClasses.push("is-selected");
112 | }
113 | if (props.isToggle) {
114 | extraClasses.push("is-toggle");
115 | }
116 | if (props.isGreen) {
117 | extraClasses.push("is-green");
118 | }
119 | if (props.isYellow) {
120 | extraClasses.push("is-yellow");
121 | }
122 | let disabled = props.disabled;
123 | return (
124 |
131 | {props.children}
132 |
133 | );
134 | }
135 |
136 | export default Button;
137 |
--------------------------------------------------------------------------------
/frontend/components/Header.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import {ReactComponent as PixelTiltLogoSvg} from "./pixel-tilt-logo.svg";
3 | import {ReactComponent as TiltLogoSvg} from "./tilt-logo.svg";
4 | import {ReactComponent as DropSvg} from "./drop.svg";
5 | import {ReactComponent as MixerSvg} from "./mixer.svg";
6 | import {ReactComponent as ScanSvg} from "./scan.svg";
7 | import {ReactComponent as ResetSvg} from "./reset.svg";
8 | import {ReactComponent as ApplySvg} from "./apply.svg";
9 | import Button from "./Button";
10 | import color from "./color";
11 |
12 | let HeaderRoot = styled.header`
13 | color: ${color.gray};
14 | box-sizing: border-box;
15 | `;
16 |
17 | let HeaderControls = styled.div`
18 | display: flex;
19 | align-items: center;
20 | min-height: 80px;
21 | background-color: ${color.grayDark};
22 | box-sizing: border-box;
23 | justify-content: space-between;
24 | `;
25 |
26 | let HeaderPixelLogoCell = styled.a`
27 | display: flex;
28 | padding-left: 72px;
29 | width: 240px;
30 | box-sizing: border-box;
31 | `;
32 |
33 | let HeaderTiltLogoCell = styled.a`
34 | padding-right: 72px;
35 | display: flex;
36 | flex-direction: row-reverse;
37 | width: 240px;
38 | box-sizing: border-box;
39 | color: ${color.gray} !important;
40 | text-decoration: none !important;
41 | `;
42 |
43 | let TiltBrandText = styled.div`
44 | font-family: "Fira Code", sans-serif;
45 | font-size: 9px;
46 | line-height: 13px;
47 | text-align: right;
48 | width: 80px;
49 | `;
50 |
51 | let HeaderStatus = styled.div`
52 | display: flex;
53 | justify-content: center;
54 | align-items: center;
55 | width: 100%;
56 | min-height: 40px;
57 | background-color: ${color.grayLight};
58 | box-sizing: border-box;
59 | `;
60 |
61 | let HeaderStatusMessage = styled.div`
62 | max-width: 650px;
63 | text-align: left;
64 | flex-grow: 1;
65 | `;
66 |
67 | let HeaderButtonRow = styled.div`
68 | display: flex;
69 | max-width: 650px;
70 | justify-content: space-between;
71 | align-items: center;
72 | height: 40px;
73 | flex-grow: 3;
74 | `;
75 |
76 | let HeaderButtonInnerRow = styled.div`
77 | display: flex;
78 | align-items: center;
79 | height: 40px;
80 | `;
81 |
82 | let LogoLink = styled.a`
83 | display: flex;
84 | `;
85 |
86 | const Header = props => {
87 | const filters = props.filters;
88 | const reset = props.reset;
89 | const apply = props.apply;
90 | const clearAndSetCheckedItems = props.clearAndSetCheckedItems;
91 | const checkedItems = props.checkedItems;
92 | const statusMessage = props.statusMessage;
93 | const hasFileSelection = props.hasFileSelection;
94 | const hasFilterSelection = Object.values(checkedItems).some(v => v);
95 |
96 | const handleChangeFilter = event => {
97 | let target = event.currentTarget;
98 | let newCheckedItems = { ...checkedItems };
99 | newCheckedItems[target.name] = !checkedItems[target.name];
100 | clearAndSetCheckedItems(newCheckedItems);
101 | };
102 |
103 | let filterEls = filters.map(item => {
104 | let name = "filter_" + item.label.toLowerCase();
105 | let selected = checkedItems[name];
106 | let label = item.label;
107 | let svg = null;
108 | if (item.label === "Color") {
109 | svg = ;
110 | } else if (item.label === "Glitch") {
111 | svg = ;
112 | } else if (item.label === "Bounding Box") {
113 | label = "Object";
114 | svg = ;
115 | }
116 | return (
117 |
128 | );
129 | });
130 |
131 | return (
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | {filterEls}
140 |
141 |
150 |
151 |
152 |
156 |
157 |
158 |
159 |
160 | A Sample App showcasing!
161 |
162 |
163 |
164 | {statusMessage}
165 |
166 |
167 | );
168 | };
169 |
170 | export default Header;
171 |
--------------------------------------------------------------------------------
/frontend/components/ImageDisplay.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 | import { ReactComponent as ImageMarkSvg } from "./tilt-imagemark.svg";
3 |
4 | const rotate = keyframes`
5 | from {
6 | transform: rotate(0deg);
7 | }
8 |
9 | to {
10 | transform: rotate(360deg);
11 | }
12 | `;
13 |
14 | let Container = styled.div`
15 | max-width: 60vw;
16 | max-height: 60vh;
17 | position: relative;
18 | `;
19 |
20 | let Overlay = styled.div`
21 | position: absolute;
22 | align-items: center;
23 | justify-content: center;
24 | top: 0;
25 | bottom: 0;
26 | right: 0;
27 | left: 0;
28 | display: none;
29 |
30 | ${Container}.is-pending & {
31 | display: flex;
32 | }
33 | `;
34 |
35 | let SpinningImageMark = styled(ImageMarkSvg)`
36 | animation: ${rotate} 2s linear infinite;
37 | width: 100px;
38 | height: 100px;
39 | opacity: 0;
40 | transition: opacity 300ms ease;
41 |
42 | ${Container}.is-pending & {
43 | opacity: 1;
44 | }
45 | `;
46 |
47 | let Img = styled.img`
48 | max-width: 60vw;
49 | max-height: 60vh;
50 | transition: opacity 300ms ease;
51 |
52 | ${Container}.is-pending & {
53 | opacity: 0.64;
54 | }
55 | `;
56 |
57 | function ImageDisplay(props) {
58 | let extraClasses = [];
59 | if (props.isPending) {
60 | extraClasses.push("is-pending");
61 | }
62 |
63 | return (
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export default ImageDisplay;
74 |
--------------------------------------------------------------------------------
/frontend/components/ImageSelect.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import fetch from "isomorphic-unfetch";
3 |
4 | let Img = styled.img`
5 | width: 220px;
6 | height: 220px;
7 | margin: 24px;
8 | object-fit: cover;
9 | cursor: pointer;
10 |
11 | &:hover {
12 | width: 236px;
13 | height: 236px;
14 | margin: 16px;
15 | }
16 | `;
17 |
18 | function ImageSelect(props) {
19 | let img = props.img;
20 | let selectImage = props.selectImage;
21 |
22 | let onClick = e => {
23 | selectImage(img);
24 | };
25 | return
;
26 | }
27 |
28 | export default ImageSelect;
29 |
--------------------------------------------------------------------------------
/frontend/components/UploadControl.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import color from "./color";
3 |
4 | let UploadControlRoot = styled.div`
5 | width: 220px;
6 | height: 220px;
7 | margin: 24px;
8 | border: 2px dashed ${color.grayLighter};
9 | border-radius: 6px;
10 | box-sizing: border-box;
11 | transition: background-color 300ms ease;
12 |
13 | &:hover {
14 | border: 2px solid ${color.grayLighter};
15 | background-color: ${color.grayLighter};
16 | }
17 | `;
18 |
19 | let UploadControlLabel = styled.label`
20 | display: flex;
21 | width: 100%;
22 | height: 100%;
23 | justify-content: center;
24 | align-items: center;
25 | font-size: 20px;
26 | font-weight: 700;
27 | line-height: 27px;
28 | color: ${color.gray};
29 | cursor: pointer;
30 | padding: 24px;
31 | box-sizing: border-box;
32 | text-align: center;
33 | `;
34 |
35 | function UploadControl(props) {
36 | let selectImage = props.selectImage;
37 |
38 | const handleUploadFile = e => {
39 | const blob = e.currentTarget.files[0];
40 | const url = URL.createObjectURL(blob);
41 | selectImage({ url, blob });
42 | };
43 |
44 | return (
45 |
46 |
52 |
53 | Click to upload a PNG
54 |
55 |
56 | );
57 | }
58 |
59 | export default UploadControl;
60 |
--------------------------------------------------------------------------------
/frontend/components/apply.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/color.js:
--------------------------------------------------------------------------------
1 | const color = {
2 | red: "#f6685c",
3 | grayDark: "#002b36",
4 | gray: "#586e75",
5 | grayLight: "#c4c4c4",
6 | grayLighter: "#ccdade",
7 | green: "#20ba31",
8 | yellow: "#fcb41e"
9 | };
10 |
11 | export default color;
12 |
--------------------------------------------------------------------------------
/frontend/components/drop.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/mixer.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/pixel-tilt-logo.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/reset.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/scan.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/frontend/components/tilt-imagemark.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/frontend/components/tilt-logo.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/frontend/k8s.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: frontend
5 | labels:
6 | app: frontend
7 | spec:
8 | selector:
9 | matchLabels:
10 | app: frontend
11 | template:
12 | metadata:
13 | labels:
14 | app: frontend
15 | tier: web
16 | spec:
17 | containers:
18 | - name: frontend
19 | image: frontend
20 | ports:
21 | - containerPort: 3000
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: frontend
27 | labels:
28 | app: frontend
29 | spec:
30 | ports:
31 | - port: 80
32 | targetPort: 3000
33 | protocol: TCP
34 | selector:
35 | app: frontend
36 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next",
8 | "build": "next build",
9 | "start": "next start"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "dependencies": {
15 | "axios": "^0.21.1",
16 | "isomorphic-unfetch": "^3.1.0",
17 | "multer": "^1.4.2",
18 | "next": "^11.0.1",
19 | "react": "^17.0.2",
20 | "react-dom": "^17.0.2",
21 | "request": "^2.88.0",
22 | "request-promise-native": "^1.0.8",
23 | "styled-components": "^5.3.0"
24 | },
25 | "optionalDependencies": {
26 | "fsevents": "^2.3.2"
27 | },
28 | "devDependencies": {
29 | "babel-plugin-inline-react-svg": "^2.0.1",
30 | "babel-plugin-styled-components": "^1.13.2"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/pages/_app.js:
--------------------------------------------------------------------------------
1 | // global app settings
2 | import "./styles.css";
3 |
4 | // Adapted from
5 | // https://nextjs.org/docs/advanced-features/custom-app
6 | export default function MyApp({ Component, pageProps }) {
7 | return
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | // Adapted from:
4 | // https://nextjs.org/docs/advanced-features/custom-document
5 | class MyDocument extends Document {
6 | static async getInitialProps(ctx) {
7 | const initialProps = await Document.getInitialProps(ctx)
8 | return { ...initialProps }
9 | }
10 |
11 | render() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 | }
25 |
26 | export default MyDocument
27 |
--------------------------------------------------------------------------------
/frontend/pages/api/image/[name].js:
--------------------------------------------------------------------------------
1 | import request from "request-promise-native";
2 |
3 | export default async (req, res) => {
4 | const {
5 | query: { name }
6 | } = req;
7 |
8 | const options = {
9 | method: "POST",
10 | url: "http://storage:8080/read",
11 | json: {
12 | Name: name
13 | }
14 | };
15 |
16 | var img;
17 | try {
18 | let result = await request(options);
19 | img = Buffer.from(result.Body, "base64");
20 | } catch (err) {
21 | console.log("Error reading from storage");
22 | console.log(err);
23 | res.statusCode = 500;
24 | res.end();
25 | return;
26 | }
27 |
28 | res.writeHead(200, {
29 | "Content-Type": "image/png",
30 | "Content-Length": img.length
31 | });
32 | res.end(img);
33 | };
34 |
--------------------------------------------------------------------------------
/frontend/pages/api/list.js:
--------------------------------------------------------------------------------
1 | import fetch from "isomorphic-unfetch";
2 |
3 | export default async (req, res) => {
4 | let list = await fetch("http://storage:8080/list").then(res => res.json());
5 | res.end(JSON.stringify(list));
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/pages/api/upload.js:
--------------------------------------------------------------------------------
1 | import multer from "multer";
2 | import util from "util";
3 | import fs from "fs";
4 | import request from "request-promise-native";
5 |
6 | const uploadStorage = multer({ dest: "uploads/" });
7 |
8 | export default async (req, res) => {
9 | let upload = util.promisify(uploadStorage.any());
10 | await upload(req, res);
11 | let filters = req.body.filters;
12 | let filename = req.files[0].filename;
13 | let filePath = "uploads/" + filename;
14 |
15 | let formData = {
16 | myFile: fs.createReadStream(filePath)
17 | };
18 | let parsedFilters = JSON.parse(filters);
19 | Object.keys(parsedFilters).forEach(item => {
20 | let key = item;
21 | let value = parsedFilters[item];
22 |
23 | formData[key] = value.toString();
24 | });
25 |
26 | const options = {
27 | method: "POST",
28 | url: "http://muxer:8080/upload",
29 | headers: {
30 | "Content-Type": "multipart/form-data"
31 | },
32 | formData: formData,
33 | json: true
34 | };
35 |
36 | const proxyResult = await request(options);
37 |
38 | res.end(JSON.stringify(proxyResult));
39 | };
40 |
41 | export const config = {
42 | api: {
43 | bodyParser: false
44 | }
45 | };
46 |
--------------------------------------------------------------------------------
/frontend/pages/index.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import Header from "../components/Header";
3 | import styled from "styled-components";
4 | import fetch from "isomorphic-unfetch";
5 | import axios from "axios";
6 | import UploadControl from "../components/UploadControl";
7 | import ImageSelect from "../components/ImageSelect";
8 | import ImageDisplay from "../components/ImageDisplay";
9 | import Button from "../components/Button";
10 |
11 | let Root = styled.div`
12 | width: 100%;
13 | min-height: 100vh;
14 | display: flex;
15 | flex-direction: column;
16 | `;
17 |
18 | let MainPane = styled.main`
19 | display: flex;
20 | justify-content: center;
21 | align-items: center;
22 | flex-grow: 1;
23 | `;
24 |
25 | let ImageGrid = styled.div`
26 | display: grid;
27 | grid-template-columns: 50% 50%;
28 | `;
29 |
30 | let HistoryButtonContainer = styled.div`
31 | position: fixed;
32 | right: 0;
33 | bottom: 0;
34 | margin: 16px;
35 | `;
36 |
37 | const Index = props => {
38 | const filters = props.filters;
39 | const defaultCheckedItems = props.defaultCheckedItems;
40 |
41 | const [historyImages, setHistoryImages] = useState([]);
42 | const [showHistory, setShowHistory] = useState(false);
43 | const [recentImages, setRecentImages] = useState(props.defaultImages);
44 | const [checkedItems, setCheckedItems] = useState(defaultCheckedItems);
45 | const [fileSelection, setFileSelection] = useState();
46 | const [resultingImage, setResultingImage] = useState("");
47 | const [applyState, setApplyState] = useState({
48 | inProgress: false,
49 | error: null
50 | });
51 |
52 | const toggleHistory = () => {
53 | if (showHistory) {
54 | setShowHistory(false);
55 | return;
56 | }
57 |
58 | fetch("/api/list")
59 | .then(res => res.json())
60 | .then(json => {
61 | let names = json.Names || [];
62 | if (names.length == 0) {
63 | setApplyState({ error: "No images in storage" });
64 | return;
65 | }
66 |
67 | reset();
68 | setHistoryImages(
69 | names.map(name => {
70 | return { url: `/api/image/${name}` };
71 | })
72 | );
73 | setShowHistory(true);
74 | })
75 | .catch(err => {
76 | setApplyState({ error: "Error fetching history: " + err });
77 | });
78 | };
79 |
80 | const selectImage = img => {
81 | let url = img.url;
82 | let blob = img.blob;
83 | let blobPromise = blob
84 | ? Promise.resolve(blob)
85 | : fetch(url).then(res => res.blob());
86 |
87 | blobPromise
88 | .then(blob => {
89 | img.blob = blob;
90 |
91 | // Append the image to the front of the recency list,
92 | // and remove it if it already exists in the list.
93 | let newRecentImages = [img].concat(
94 | recentImages.filter(existing => {
95 | return existing.url != url;
96 | })
97 | );
98 | setRecentImages(newRecentImages);
99 | setFileSelection(img);
100 | setShowHistory(false);
101 | })
102 | .catch(err => {
103 | setApplyState({ error: "Error fetching image: " + err });
104 | setShowHistory(false);
105 | });
106 | };
107 |
108 | const statusMessage = () => {
109 | if (applyState.inProgress) {
110 | return "Applying filter…";
111 | }
112 |
113 | if (applyState.error) {
114 | return applyState.error;
115 | }
116 |
117 | if (resultingImage || fileSelection) {
118 | return "Select one or more filters and apply";
119 | }
120 |
121 | return "Upload or Select an Image. Apply filters once selected";
122 | };
123 |
124 | const clearAndSetCheckedItems = checkedItems => {
125 | setResultingImage("");
126 | setCheckedItems(checkedItems);
127 | setApplyState({ inProgress: false, error: null });
128 | };
129 |
130 | const reset = () => {
131 | setShowHistory(false);
132 | setResultingImage("");
133 | setFileSelection(null);
134 | setCheckedItems(defaultCheckedItems);
135 | setApplyState({ inProgress: false, error: null });
136 | };
137 |
138 | const apply = async () => {
139 | if (!fileSelection) {
140 | throw new Error("internal error: no file to apply filters on");
141 | }
142 |
143 | const data = new FormData();
144 | const file = fileSelection.blob;
145 | data.append("file", file);
146 | const filters = Object.keys(checkedItems)
147 | .filter(key => checkedItems[key])
148 | .reduce((res, key) => ((res[key] = checkedItems[key]), res), {});
149 | data.append("filters", JSON.stringify(filters));
150 | setApplyState({ inProgress: true });
151 |
152 | axios
153 | .post("/api/upload", data, {})
154 | .then(response => {
155 | setResultingImage(response.data.name);
156 | setApplyState({ inProgress: false });
157 | })
158 | .catch(err => {
159 | setApplyState({ error: "Error applying filter: " + err });
160 | });
161 | };
162 |
163 | const renderContent = () => {
164 | if (resultingImage) {
165 | return ;
166 | }
167 |
168 | if (fileSelection) {
169 | return (
170 |
174 | );
175 | }
176 |
177 | let cells = [];
178 | cells.push(
179 |
180 | );
181 | cells = cells.concat(
182 | recentImages.slice(0, 3).map((img, i) => {
183 | return (
184 |
185 | );
186 | })
187 | );
188 |
189 | if (showHistory) {
190 | cells = historyImages.slice(0, 10).map((img, i) => {
191 | return (
192 |
193 | );
194 | });
195 | }
196 |
197 | return {cells};
198 | };
199 |
200 | return (
201 |
202 |
211 | {renderContent()}
212 |
213 |
221 |
222 |
223 | );
224 | };
225 |
226 | Index.getInitialProps = async function() {
227 | const filtersData = await fetch("http://muxer:8080/filters").then(res =>
228 | res.json()
229 | );
230 |
231 | const staticImageUrls = ["/baby-bear.png", "/plane.png", "/duck.png"];
232 | let defaultImages = staticImageUrls.map(url => {
233 | return { url: url };
234 | });
235 |
236 | const defaultCheckedItems = {};
237 | filtersData.forEach(
238 | c => (defaultCheckedItems["filter_" + c.label.toLowerCase()] = false)
239 | );
240 |
241 | return {
242 | filters: filtersData,
243 | defaultCheckedItems: defaultCheckedItems,
244 | defaultImages: defaultImages
245 | };
246 | };
247 |
248 | export default Index;
249 |
--------------------------------------------------------------------------------
/frontend/pages/styles.css:
--------------------------------------------------------------------------------
1 | /** global styles */
2 |
3 | body {
4 | margin: 0;
5 | font-size: 16px;
6 | font-family: 'Noto Sans', sans-serif;
7 | /* filter: invert(1); */
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/public/baby-bear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/baby-bear.png
--------------------------------------------------------------------------------
/frontend/public/duck.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/duck.png
--------------------------------------------------------------------------------
/frontend/public/plane.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/plane.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/tilt-dev/pixeltilt
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/fogleman/gg v1.3.0
7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
8 | github.com/peterbourgon/diskv/v3 v3.0.0
9 | github.com/pkg/errors v0.9.1
10 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a
11 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect
12 | )
13 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30=
2 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
3 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
4 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
5 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
6 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
7 | github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
8 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
9 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
10 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
11 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
12 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
13 | github.com/peterbourgon/diskv/v3 v3.0.0 h1:iPZxiSeh/S8JIAl2rIhCSYlUIgZWjd9mYswxZfeUI3s=
14 | github.com/peterbourgon/diskv/v3 v3.0.0/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
15 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
16 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
17 | github.com/sug0/go-exit v0.0.0-20190205235601-47997d719c0d/go.mod h1:0yI0wMw4CyNlMjIpDpIrImEHeit6waWoaJlGQRMCymA=
18 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a h1:VASyCcRjH8AesA39NkpQ5/A3Mp/jhTMgxWptoszmUrY=
19 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a/go.mod h1:Su9SgmOCmAqkWQNW/jvNDY4NJn5vLKOhTpoq/7QCcc8=
20 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
21 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
22 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
23 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
24 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
25 |
--------------------------------------------------------------------------------
/muxer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pixeltilt-base
2 |
3 | COPY ../render/api render/api/
4 | COPY ../storage/api storage/api/
5 | COPY ../storage/client storage/client/
6 | COPY muxer/ muxer/
7 |
8 | RUN --mount=type=cache,target=/cache/gomod \
9 | --mount=type=cache,target=/cache/gobuild,sharing=locked \
10 | find . -name "*.go" && \
11 | go mod vendor && \
12 | go build -mod=vendor -o /usr/local/bin/muxer ./muxer
13 |
14 | CMD ["/usr/local/bin/muxer"]
15 |
--------------------------------------------------------------------------------
/muxer/k8s.yaml:
--------------------------------------------------------------------------------
1 |
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: muxer
6 | labels:
7 | app: muxer
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: muxer
12 | template:
13 | metadata:
14 | labels:
15 | app: muxer
16 | spec:
17 | containers:
18 | - name: muxer
19 | image: muxer
20 | ports:
21 | - containerPort: 8080
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: muxer
27 | labels:
28 | app: muxer
29 | spec:
30 | ports:
31 | - port: 8080
32 | targetPort: 8080
33 | protocol: TCP
34 | selector:
35 | app: muxer
36 |
--------------------------------------------------------------------------------
/muxer/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/base64"
5 | "encoding/json"
6 | "fmt"
7 | "io/ioutil"
8 | "log"
9 | "net/http"
10 | "net/url"
11 | "os"
12 | "sort"
13 | "strings"
14 |
15 | "github.com/pkg/errors"
16 |
17 | "github.com/tilt-dev/pixeltilt/render/api"
18 |
19 | "github.com/tilt-dev/pixeltilt/storage/client"
20 | )
21 |
22 | type filter struct {
23 | Label string `json:"label"`
24 | URL string `json:"url"`
25 | NeedsOriginal bool `json:"needsoriginal"`
26 | }
27 |
28 | // Order matters!!
29 | var enabledFilters = []filter{
30 | filter{"Color", "http://color:8080", false},
31 | filter{"Glitch", "http://glitch:8080", false},
32 | filter{"Bounding Box", "http://bounding-box:8080", true},
33 | }
34 |
35 | var storage client.Storage
36 |
37 | func main() {
38 | fmt.Println("Muxer running!")
39 | port := "8080"
40 | if len(os.Args) > 1 {
41 | port = os.Args[1]
42 | }
43 |
44 | var err error
45 | storage, err = client.NewStorageClient("http://storage:8080")
46 | if err != nil {
47 | log.Fatalf("initializing storage client: %v", err)
48 | }
49 |
50 | http.HandleFunc("/", index)
51 | http.HandleFunc("/filters", filters)
52 | http.HandleFunc("/images", images)
53 | http.HandleFunc("/upload", upload)
54 | http.HandleFunc("/access/", access)
55 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
56 | }
57 |
58 | func index(w http.ResponseWriter, r *http.Request) {
59 | html := `
60 |
61 |
62 |
63 | Document
64 |
65 |
66 |
88 |
`
89 |
90 | // show current db entries
91 | for _, key := range imageKeys {
92 | html += "" + key + "
"
93 | }
94 |
95 | html += `
96 |
97 | `
98 |
99 | _, err = w.Write([]byte(html))
100 | if err != nil {
101 | fmt.Println(err)
102 | return
103 | }
104 | }
105 |
106 | func access(w http.ResponseWriter, r *http.Request) {
107 | key := strings.Split(r.URL.Path, "/")[2]
108 | image, err := storage.Read(key)
109 | if err != nil {
110 | fmt.Println(err)
111 | return
112 | }
113 | w.Header().Set("Content-Type", "image/png")
114 | _, err = w.Write(image)
115 | if err != nil {
116 | fmt.Println(err)
117 | return
118 | }
119 | }
120 |
121 | func filters(w http.ResponseWriter, r *http.Request) {
122 | j, err := json.MarshalIndent(enabledFilters, "", " ")
123 | if err != nil {
124 | handleHTTPErr(w, err)
125 | return
126 | }
127 |
128 | w.Header().Set("Content-Type", "application/json")
129 |
130 | _, err = w.Write(j)
131 | if err != nil {
132 | fmt.Println(err)
133 | }
134 | }
135 |
136 | func images(w http.ResponseWriter, r *http.Request) {
137 | imageKeys, err := storage.List()
138 | if err != nil {
139 | handleHTTPErr(w, err)
140 | return
141 | }
142 |
143 | j, err := json.MarshalIndent(imageKeys, "", " ")
144 | if err != nil {
145 | handleHTTPErr(w, err)
146 | return
147 | }
148 |
149 | w.Header().Set("Content-Type", "application/json")
150 | _, err = w.Write(j)
151 | if err != nil {
152 | fmt.Println(err)
153 | }
154 | }
155 |
156 | type uploadResponse struct {
157 | Name string `json:"name"`
158 | }
159 |
160 | // TODO(dmiller): this should return the image URL instead of the image itself
161 | func upload(w http.ResponseWriter, r *http.Request) {
162 | originalImageBytes, filename, err := fileFromRequest(r)
163 | if err != nil {
164 | handleHTTPErr(w, err)
165 | return
166 | }
167 |
168 | err = r.ParseForm()
169 | if err != nil {
170 | handleHTTPErr(w, httpStatusError{http.StatusBadRequest, err})
171 | return
172 | }
173 |
174 | filters := filtersFromValues(r.PostForm)
175 | sort.Ints(filters)
176 |
177 | modifiedImage, err := applyFilters(originalImageBytes, filters)
178 | if err != nil {
179 | handleHTTPErr(w, err)
180 | return
181 | }
182 |
183 | // save to db
184 | encoded := base64.StdEncoding.EncodeToString(modifiedImage)
185 | name, err := storage.Write(filename, []byte(encoded))
186 | if err != nil {
187 | handleHTTPErr(w, err)
188 | return
189 | }
190 |
191 | resp := uploadResponse{
192 | Name: name,
193 | }
194 |
195 | w.Header().Set("Content-Type", "application/json")
196 | err = json.NewEncoder(w).Encode(resp)
197 | if err != nil {
198 | log.Printf("Error JSON encoding response: %v", err)
199 | }
200 | }
201 |
202 | func filtersFromValues(values url.Values) []int {
203 | var ret []int
204 | message := "Filters to apply: "
205 | for paramName := range values {
206 | fmt.Printf("Checking if %s is enabling a filter...\n", paramName)
207 | if !strings.HasPrefix(paramName, "filter_") {
208 | continue
209 | }
210 | name := strings.TrimPrefix(paramName, "filter_")
211 | for i := 0; i < len(enabledFilters); i++ {
212 | if strings.ToLower(enabledFilters[i].Label) == name {
213 | message += enabledFilters[i].Label + " "
214 | ret = append(ret, i)
215 | }
216 | }
217 | }
218 | fmt.Println(message)
219 | return ret
220 | }
221 |
222 | type httpStatusError struct {
223 | code int
224 | err error
225 | }
226 |
227 | func (e httpStatusError) Error() string {
228 | return e.err.Error()
229 | }
230 |
231 | func fileFromRequest(r *http.Request) (image []byte, filename string, err error) {
232 | // receive file
233 | file, header, err := r.FormFile("myFile")
234 | if err != nil {
235 | return nil, "", errors.Wrap(err, "getting file from request")
236 | }
237 | defer file.Close()
238 | fmt.Printf("Uploaded File: %+v\tFile Size: %+v\tMIME: %+v\n", header.Filename, header.Size, header.Header)
239 |
240 | fileBytes, err := ioutil.ReadAll(file)
241 | if err != nil {
242 | return nil, "", errors.Wrap(err, "reading uploaded file from request")
243 | }
244 |
245 | imageType := http.DetectContentType(fileBytes)
246 | if imageType != "image/png" {
247 | // https://www.bennadel.com/blog/2434-http-status-codes-for-invalid-data-400-vs-422.htm
248 | return nil, "", httpStatusError{http.StatusUnprocessableEntity, fmt.Errorf("invalid image type: expected \"image/png\", got: %s", imageType)}
249 | }
250 |
251 | return fileBytes, header.Filename, nil
252 | }
253 |
254 | func applyFilters(imageBytes []byte, filterIndexes []int) ([]byte, error) {
255 | currentImageBytes := append([]byte{}, imageBytes...)
256 |
257 | for _, f := range filterIndexes {
258 | var err error
259 | fmt.Println("Applying Filter:", enabledFilters[f].Label)
260 | currentImageBytes, err = applyFilter(enabledFilters[f], currentImageBytes, imageBytes)
261 | if err != nil {
262 | // return nil, fmt.Errorf("Error applying %s: %v", enabledFilters[f].Label, err)
263 | }
264 | }
265 | return currentImageBytes, nil
266 | }
267 |
268 | func applyFilter(filter filter, imageBytes []byte, originalImageBytes []byte) ([]byte, error) {
269 | rr := api.RenderRequest{Image: imageBytes}
270 | if filter.NeedsOriginal {
271 | rr.OriginalImage = originalImageBytes
272 | }
273 |
274 | resp, err := api.PostRequest(rr, filter.URL)
275 | if err != nil {
276 | return nil, err
277 | }
278 |
279 | return resp.Image, nil
280 | }
281 |
282 | func handleHTTPErr(w http.ResponseWriter, err error) {
283 | fmt.Println(err.Error())
284 | status := http.StatusInternalServerError
285 | if se, ok := err.(httpStatusError); ok {
286 | status = se.code
287 | }
288 | http.Error(w, err.Error(), status)
289 | }
290 |
--------------------------------------------------------------------------------
/object-detector/k8s.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: max-object-detector
5 | labels:
6 | app: max-object-detector
7 | spec:
8 | selector:
9 | matchLabels:
10 | app: max-object-detector
11 | template:
12 | metadata:
13 | labels:
14 | app: max-object-detector
15 | tier: backend
16 | spec:
17 | containers:
18 | - name: max-object-detector
19 | image: codait/max-object-detector
20 | ports:
21 | - containerPort: 5000
22 | resources:
23 | requests:
24 | cpu: "10m"
25 | ---
26 | apiVersion: v1
27 | kind: Service
28 | metadata:
29 | name: max-object-detector
30 | labels:
31 | app: max-object-detector
32 | spec:
33 | ports:
34 | - port: 5000
35 | targetPort: 5000
36 | protocol: TCP
37 | selector:
38 | app: max-object-detector
39 |
--------------------------------------------------------------------------------
/pixeltilt.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/pixeltilt.gif
--------------------------------------------------------------------------------
/render/api/api.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io/ioutil"
8 | "log"
9 | "net/http"
10 |
11 | "github.com/pkg/errors"
12 | )
13 |
14 | type RenderRequest struct {
15 | Image []byte
16 | // OriginalImage is optional
17 | OriginalImage []byte
18 | }
19 |
20 | type RenderReply struct {
21 | Image []byte
22 | }
23 |
24 | type Renderer func(req RenderRequest) (RenderReply, error)
25 |
26 | func HttpRenderHandler(renderer Renderer) func(w http.ResponseWriter, req *http.Request) {
27 | return func(w http.ResponseWriter, req *http.Request) {
28 | rr, err := ReadRequest(req)
29 | if err != nil {
30 | http.Error(w, err.Error(), http.StatusBadRequest)
31 | }
32 |
33 | resp, err := renderer(rr)
34 | if err != nil {
35 | msg := fmt.Sprintf("error transforming image: %v", err)
36 | log.Println(msg)
37 | http.Error(w, msg, http.StatusInternalServerError)
38 | }
39 |
40 | err = WriteResponse(w, resp)
41 | if err != nil {
42 | log.Printf("error writing response: %v", err)
43 | }
44 | }
45 | }
46 |
47 | func ReadRequest(req *http.Request) (RenderRequest, error) {
48 | d := json.NewDecoder(req.Body)
49 | d.DisallowUnknownFields()
50 | var rr RenderRequest
51 | err := d.Decode(&rr)
52 | return rr, errors.Wrap(err, "decoding request body")
53 | }
54 |
55 | func WriteResponse(w http.ResponseWriter, resp RenderReply) error {
56 | return errors.Wrap(json.NewEncoder(w).Encode(resp), "encoding response body")
57 | }
58 |
59 | func PostRequest(req RenderRequest, url string) (RenderReply, error) {
60 | var buf bytes.Buffer
61 | err := json.NewEncoder(&buf).Encode(req)
62 | if err != nil {
63 | return RenderReply{}, errors.Wrap(err, "encoding request body")
64 | }
65 |
66 | resp, err := http.Post(url, "image/png", &buf)
67 | if err != nil {
68 | return RenderReply{}, errors.Wrap(err, "making post request")
69 | }
70 |
71 | if resp.StatusCode != http.StatusOK {
72 | body, err := ioutil.ReadAll(resp.Body)
73 | if err != nil {
74 | return RenderReply{}, errors.Wrapf(err, "reading response body w/ status %s", resp.Status)
75 | }
76 | return RenderReply{}, fmt.Errorf("post request returned status %s: %s", resp.Status, string(body))
77 | }
78 |
79 | var reply RenderReply
80 | d := json.NewDecoder(resp.Body)
81 | d.DisallowUnknownFields()
82 | err = d.Decode(&reply)
83 | return reply, errors.Wrap(err, "decoding reply")
84 | }
85 |
--------------------------------------------------------------------------------
/storage/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pixeltilt-base
2 |
3 | COPY storage/ storage/
4 |
5 | RUN --mount=type=cache,target=/cache/gomod \
6 | --mount=type=cache,target=/cache/gobuild,sharing=locked \
7 | go mod vendor && \
8 | go build -mod=vendor -o /usr/local/bin/storage ./storage
9 |
10 | CMD ["/usr/local/bin/storage"]
11 |
--------------------------------------------------------------------------------
/storage/api/api.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | type WriteRequest struct {
4 | Name string
5 | Body []byte
6 | }
7 |
8 | type WriteResponse struct {
9 | Name string
10 | }
11 |
12 | type ReadRequest struct {
13 | Name string
14 | }
15 |
16 | type ReadResponse struct {
17 | Body []byte
18 | }
19 |
20 | type ListResponse struct {
21 | Names []string
22 | }
23 |
--------------------------------------------------------------------------------
/storage/client/client.go:
--------------------------------------------------------------------------------
1 | package client
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "io/ioutil"
7 | "net/http"
8 | "net/url"
9 |
10 | "github.com/tilt-dev/pixeltilt/storage/api"
11 |
12 | "github.com/pkg/errors"
13 | )
14 |
15 | type Storage struct {
16 | url *url.URL
17 | }
18 |
19 | func NewStorageClient(rawurl string) (Storage, error) {
20 | u, err := url.Parse(rawurl)
21 | if err != nil {
22 | return Storage{}, errors.Wrapf(err, "parsing url %q", rawurl)
23 | }
24 | return Storage{url: u}, nil
25 | }
26 |
27 | func (sc *Storage) Write(name string, b []byte) (string, error) {
28 | wr := api.WriteRequest{Name: name, Body: b}
29 | buf := &bytes.Buffer{}
30 | err := json.NewEncoder(buf).Encode(wr)
31 | if err != nil {
32 | return "", errors.Wrap(err, "encoding write request")
33 | }
34 | u, _ := url.Parse(sc.url.String())
35 | u.Path = "/write"
36 | resp, err := http.Post(u.String(), "application/json", buf)
37 | if err != nil {
38 | return "", errors.Wrap(err, "http posting")
39 | }
40 |
41 | if resp.StatusCode != http.StatusOK {
42 | respBody, err := ioutil.ReadAll(resp.Body)
43 | if err != nil {
44 | return "", errors.Wrap(err, "reading response body on non-200 response")
45 | }
46 | return "", errors.Errorf("http status %s, body %q", resp.Status, string(respBody))
47 | }
48 |
49 | var wresp api.WriteResponse
50 | err = json.NewDecoder(resp.Body).Decode(&wresp)
51 | if err != nil {
52 | errors.Wrap(err, "reading response body on 200 response")
53 | }
54 |
55 | return wresp.Name, nil
56 | }
57 |
58 | func (sc *Storage) Read(name string) ([]byte, error) {
59 | rreq := api.ReadRequest{Name: name}
60 | buf := &bytes.Buffer{}
61 | err := json.NewEncoder(buf).Encode(rreq)
62 | if err != nil {
63 | return nil, errors.Wrap(err, "encoding read request")
64 | }
65 |
66 | u, _ := url.Parse(sc.url.String())
67 | u.Path = "/read"
68 | resp, err := http.Post(u.String(), "application/json", buf)
69 | if err != nil {
70 | return nil, errors.Wrap(err, "http posting")
71 | }
72 |
73 | if resp.StatusCode != http.StatusOK {
74 | respBody, err := ioutil.ReadAll(resp.Body)
75 | if err != nil {
76 | return nil, errors.Wrap(err, "reading response body on non-200 response")
77 | }
78 | return nil, errors.Errorf("http status %s, body %q", resp.Status, string(respBody))
79 | }
80 |
81 | var rresp api.ReadResponse
82 | err = json.NewDecoder(resp.Body).Decode(&rresp)
83 | if err != nil {
84 | return nil, errors.Wrap(err, "decoding http response")
85 | }
86 |
87 | return rresp.Body, nil
88 | }
89 |
90 | func (sc *Storage) List() ([]string, error) {
91 | u, _ := url.Parse(sc.url.String())
92 | u.Path = "/list"
93 | resp, err := http.Get(u.String())
94 | if err != nil {
95 | return nil, errors.Wrap(err, "http getting")
96 | }
97 |
98 | if resp.StatusCode != http.StatusOK {
99 | respBody, err := ioutil.ReadAll(resp.Body)
100 | if err != nil {
101 | return nil, errors.Wrap(err, "reading response body on non-200 response")
102 | }
103 | return nil, errors.Errorf("http status %s, body %q", resp.Status, string(respBody))
104 | }
105 |
106 | var lr api.ListResponse
107 | dec := json.NewDecoder(resp.Body)
108 | dec.DisallowUnknownFields()
109 | err = dec.Decode(&lr)
110 | if err != nil {
111 | return nil, errors.Wrap(err, "decoding http response")
112 | }
113 |
114 | // Don't return null because null is the root of all evil
115 | if lr.Names == nil {
116 | return []string{}, nil
117 | }
118 |
119 | return lr.Names, nil
120 | }
121 |
--------------------------------------------------------------------------------
/storage/k8s.yaml:
--------------------------------------------------------------------------------
1 |
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: storage
6 | labels:
7 | app: storage
8 | spec:
9 | selector:
10 | matchLabels:
11 | app: storage
12 | template:
13 | metadata:
14 | labels:
15 | app: storage
16 | spec:
17 | containers:
18 | - name: storage
19 | image: storage
20 | ports:
21 | - containerPort: 8080
22 | ---
23 | apiVersion: v1
24 | kind: Service
25 | metadata:
26 | name: storage
27 | labels:
28 | app: storage
29 | spec:
30 | ports:
31 | - port: 8080
32 | targetPort: 8080
33 | protocol: TCP
34 | selector:
35 | app: storage
36 |
--------------------------------------------------------------------------------
/storage/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/base64"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | "os"
10 | "path/filepath"
11 | "strings"
12 | "time"
13 |
14 | "github.com/tilt-dev/pixeltilt/storage/api"
15 |
16 | "github.com/peterbourgon/diskv/v3"
17 | )
18 |
19 | var d = diskv.New(diskv.Options{
20 | BasePath: "diskv",
21 | Transform: func(s string) []string { return []string{} },
22 | CacheSizeMax: 1024 * 1024, // 1MB
23 | })
24 |
25 | func main() {
26 | fmt.Println("Storage running!")
27 | port := "8080"
28 | if len(os.Args) > 1 {
29 | port = os.Args[1]
30 | }
31 | http.HandleFunc("/write", write)
32 | http.HandleFunc("/read", read)
33 | http.HandleFunc("/list", list)
34 | http.HandleFunc("/flush", flush)
35 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
36 | }
37 |
38 | func read(w http.ResponseWriter, r *http.Request) {
39 | var rreq api.ReadRequest
40 | decoder := json.NewDecoder(r.Body)
41 | decoder.DisallowUnknownFields()
42 | err := decoder.Decode(&rreq)
43 | if err != nil {
44 | http.Error(w, fmt.Sprintf("error decoding request: %v", err), http.StatusBadRequest)
45 | return
46 | }
47 |
48 | if rreq.Name == "" {
49 | http.Error(w, "no name specified", http.StatusBadRequest)
50 | return
51 | }
52 |
53 | image, err := d.Read(rreq.Name)
54 | if err != nil {
55 | // TODO: depending on err, this might be a 404 or something
56 | http.Error(w, fmt.Sprintf("error reading image from storage: %v", err), http.StatusInternalServerError)
57 | return
58 | }
59 |
60 | decoded, err := base64.StdEncoding.DecodeString(string(image))
61 | if err != nil {
62 | http.Error(w, fmt.Sprintf("error decoding image from storage: %v", err), http.StatusInternalServerError)
63 | return
64 | }
65 |
66 | w.Header().Set("Content-Type", "image/png")
67 |
68 | rresp := api.ReadResponse{Body: decoded}
69 | err = json.NewEncoder(w).Encode(rresp)
70 | if err != nil {
71 | http.Error(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError)
72 | return
73 | }
74 | }
75 |
76 | func write(w http.ResponseWriter, r *http.Request) {
77 | var wr api.WriteRequest
78 | decoder := json.NewDecoder(r.Body)
79 | decoder.DisallowUnknownFields()
80 | err := decoder.Decode(&wr)
81 | if err != nil {
82 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
83 | return
84 | }
85 |
86 | if wr.Name == "" {
87 | http.Error(w, "no Name specified", http.StatusBadRequest)
88 | return
89 | }
90 |
91 | if len(wr.Body) == 0 {
92 | http.Error(w, "no file specified", http.StatusBadRequest)
93 | return
94 | }
95 |
96 | filenameWithoutExtension := strings.TrimSuffix(wr.Name, filepath.Ext(wr.Name))
97 | name := fmt.Sprintf("%s-%s.png", filenameWithoutExtension, time.Now().Format("2006-01-02-15-04-05"))
98 |
99 | err = d.Write(name, wr.Body)
100 | if err != nil {
101 | http.Error(w, err.Error(), http.StatusInternalServerError)
102 | return
103 | }
104 |
105 | response := api.WriteResponse{Name: name}
106 | err = json.NewEncoder(w).Encode(&response)
107 | }
108 |
109 | func list(w http.ResponseWriter, r *http.Request) {
110 | var lr api.ListResponse
111 | for key := range d.Keys(nil) {
112 | lr.Names = append(lr.Names, key)
113 | }
114 |
115 | err := json.NewEncoder(w).Encode(&lr)
116 | if err != nil {
117 | http.Error(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError)
118 | return
119 | }
120 | }
121 |
122 | func flush(w http.ResponseWriter, r *http.Request) {
123 | dir, err := os.ReadDir("/app/diskv")
124 | if err == nil {
125 | for _, d := range dir {
126 | _ = os.RemoveAll(filepath.Join("diskv", d.Name()))
127 | }
128 | } else if !os.IsNotExist(err) {
129 | http.Error(w, fmt.Sprintf("error reading diskv directory: %v", err), http.StatusInternalServerError)
130 | return
131 | }
132 |
133 | _, _ = w.Write([]byte("Database entries deleted!\n"))
134 | }
135 |
--------------------------------------------------------------------------------