commit d7b7e01e9243593311ac9ec591ddf0fa630b2484
Author: bakonpancakz
Date: Sun May 24 19:58:20 2026 -0700
Initial Release
diff --git a/.github/background.png b/.github/background.png
new file mode 100644
index 0000000..5c75ded
Binary files /dev/null and b/.github/background.png differ
diff --git a/.github/preview.png b/.github/preview.png
new file mode 100644
index 0000000..a9b21df
Binary files /dev/null and b/.github/preview.png differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d16617f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+docker-compose.yml
+data
+.env
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e818dea
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,49 @@
+# --- Build ---
+
+FROM golang:1.25.2-trixie AS build
+WORKDIR /app
+COPY . .
+
+# Setup Compiler
+RUN apt-get update
+RUN apt-get install -y gcc wget tar
+RUN rm -rf /var/lib/apt/lists/*
+
+# Setup Tensorflow
+RUN wget -O tensorflow.tar.gz -q https://storage.googleapis.com/tensorflow/versions/2.18.0/libtensorflow-cpu-linux-x86_64.tar.gz && \
+ tar -C /usr/local -xzf tensorflow.tar.gz && \
+ rm tensorflow.tar.gz
+
+# Build Application
+ENV CGO_ENABLED=1 \
+ CGO_CFLAGS="-I/usr/local/include" \
+ CGO_LDFLAGS="-L/usr/local/lib -ltensorflow"
+
+RUN go build -o stickerboard.elf main.go
+
+# --- Runtime ---
+
+FROM debian:trixie-slim AS runtime
+WORKDIR /app
+RUN mkdir -p /data
+
+# Install Dependencies
+RUN apt-get update
+RUN apt-get install -y ffmpeg
+RUN rm -rf /var/lib/apt/lists/*
+
+# Copy Application
+COPY --from=build /usr/local/lib /usr/local/lib
+COPY --from=build /app/resources ./resources
+COPY --from=build /app/stickerboard.elf .
+
+# Update Environment
+ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
+ENV TF_CPP_MIN_LOG_LEVEL=2
+ENV HTTP_ADDRESS="0.0.0.0:8080"
+ENV DATA_DIRECTORY=/data
+
+# Start Application
+EXPOSE 8080
+CMD ["./stickerboard.elf"]
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8762813
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 bakonpancakz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1a54372
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# 🍡 `pancakz-stickerboard`
+
+> ⚠ This service is no longer available.
+
+A program that allows trolls to paste inappropriate stickers on my Profile.
+Contribute to the efforts [here](https://stickers.pancakz.net)!
+
+
+
+
some basic css edits will get you far
+
+
+### *Featuring...*
+- AI Image Moderation using [nsfw_model](https://github.com/GantMan/nsfw_model)
+- Resonably Efficient Rendering
+- Support for **WEBP**, **PNG**, **JPEG**, **Animated GIF** Formats
+- Awesome Sauce Hatsune Miku Themed Website
+
+## ⚙️ Configuration
+This application requires that [CGO](https://go.dev/wiki/cgo), [FFMPEG](https://www.ffmpeg.org/) and
+[Tensorflow](https://www.tensorflow.org/install/lang_c) be installed and setup on your machine.
+
+> Additionally you must include the `resources` folder with the **executable**.
+
+You can set these using environment variables or a `.env` file in the working directory.
+Required Variables are marked with an asterisk `*`.
+
+| Environment Variable | Default | Description |
+| -------------------- | ---------------- | --------------------------------------------------------------------- |
+| `DATA_DIRECTORY` | `./data` | Path to Data Directory |
+| `HTTP_PROXY_HEADER` | *(none)* | Retrieve IP Address (for ratelimiting) from the following HTTP Header |
+| `HTTP_ADDRESS` | `localhost:8080` | Accept Incoming Requests on given Host and Port |
+| `TLS_ENABLED` | `false` | Enable TLS? |
+| `TLS_CERT` | *(none)* | Path to Certificate |
+| `TLS_KEY` | *(none)* | Path to Private Key |
+| `TLS_CA` | *(none)* | Path to CA Bundle |
+
+- **💡 TIP:** You can set a custom background by placing a `854x480px PNG` named **background.png** in the **data directory**.
diff --git a/StartLinux.sh b/StartLinux.sh
new file mode 100644
index 0000000..c01c599
--- /dev/null
+++ b/StartLinux.sh
@@ -0,0 +1,11 @@
+# Download Tensorflow from: https://www.tensorflow.org/install/lang_c
+# Move extracted contents of './include => /usr/local/include'
+# Move extracted contents of './lib => /usr/local/lib'
+# Update Library cache with 'sudo ldconfig'
+
+export TF_CPP_MIN_LOG_LEVEL=2
+export CGO_ENABLED=1
+export CGO_CFLAGS="-I/usr/local/include"
+export CGO_LDFLAGS="-L/usr/local/lib -ltensorflow"
+
+go run main.go
diff --git a/StartWindows.ps1 b/StartWindows.ps1
new file mode 100644
index 0000000..d98cd97
--- /dev/null
+++ b/StartWindows.ps1
@@ -0,0 +1,9 @@
+# Download Tensorflow from: https://www.tensorflow.org/install/lang_c
+# Extract ZIP contents to 'C:\lib\tensorflow'
+# Add 'C:\lib\tensorflow\lib' to System PATH
+
+$env:TF_CPP_MIN_LOG_LEVEL=2
+$env:CGO_ENABLED = "1"
+$env:CGO_CFLAGS = "-IC:\lib\tensorflow\include"
+$env:CGO_LDFLAGS = "-LC:\lib\tensorflow\lib -ltensorflow"
+go run main.go
\ No newline at end of file
diff --git a/env/Configuration.go b/env/Configuration.go
new file mode 100644
index 0000000..f008f97
--- /dev/null
+++ b/env/Configuration.go
@@ -0,0 +1,88 @@
+package env
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "log"
+ "os"
+ "strconv"
+)
+
+const (
+ MAX_FORM_BYTES = 1 << 24
+ FILE_MODE = os.FileMode(0770)
+ STICKERBOARD_FILENAME = "stickerboard.webp"
+)
+
+var (
+ HTTP_TLS *tls.Config // http: TLS Configuration
+ HTTP_PROXY_HEADER = envString("HTTP_PROXY_HEADER", "") // http: Retrieve IP Address from Following HTTP Header
+ HTTP_ADDRESS = envString("HTTP_ADDRESS", "localhost:8080") // http: Address to Listen for Requests on
+ TLS_ENABLED = envString("TLS_ENABLED", "false") == "true" // http: Enable TLS?
+ TLS_CERT = envString("TLS_CERT", "tls_crt.pem") // http: Path to TLS Certificate
+ TLS_KEY = envString("TLS_KEY", "tls_key.pem") // http: Path to TLS Key
+ TLS_CA = envString("TLS_CA", "tls_ca.pem") // http: Path to TLS CA Bundle
+ DATA_DIRECTORY = envString("DATA_DIRECTORY", "data") // env: Data Directory
+)
+
+func init() {
+ // Create Data Directory
+ if err := os.MkdirAll(DATA_DIRECTORY, FILE_MODE); err != nil {
+ log.Fatalln("[env/data] Create Directory Error:", err)
+ }
+
+ // Load and Parse TLS Configuration from Disk
+ if TLS_ENABLED {
+ cert, err := tls.LoadX509KeyPair(TLS_CERT, TLS_KEY)
+ if err != nil {
+ log.Fatalln("[env/tls] Cannot Load Keypair", err)
+ }
+ caBytes, err := os.ReadFile(TLS_CA)
+ if err != nil {
+ log.Fatalln("[env/tls] Cannot Read CA File", err)
+ }
+ caPool := x509.NewCertPool()
+ if !caPool.AppendCertsFromPEM(caBytes) {
+ log.Fatalln("[env/tls] Cannot Append Certificates")
+ }
+ HTTP_TLS = &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ ClientCAs: caPool,
+ MinVersion: tls.VersionTLS13,
+ MaxVersion: tls.VersionTLS13,
+ CipherSuites: []uint16{
+ tls.TLS_AES_128_GCM_SHA256,
+ tls.TLS_AES_256_GCM_SHA384,
+ tls.TLS_CHACHA20_POLY1305_SHA256,
+ },
+ }
+ }
+}
+
+// Reads String from Environment
+func envString(key, defaultValue string) string {
+ systemValue := os.Getenv(key)
+ if systemValue == "" {
+ if defaultValue == "\x00" {
+ fmt.Printf("[env] Environment Variable '%s' is undefined\n", key)
+ os.Exit(2)
+ }
+ return defaultValue
+ }
+ return systemValue
+}
+
+// Read Number from Environment
+func envNumber(key string, defaultValue int) int {
+ systemValue := os.Getenv(key)
+ if systemValue == "" {
+ return defaultValue
+ }
+ v, err := strconv.Atoi(systemValue)
+ if err != nil {
+ fmt.Printf("[env] Environment Variable '%s' is not a integer: %s\n", key, err)
+ os.Exit(2)
+ }
+ return v
+}
diff --git a/env/Database.go b/env/Database.go
new file mode 100644
index 0000000..5252e75
--- /dev/null
+++ b/env/Database.go
@@ -0,0 +1,75 @@
+package env
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "os"
+ "path"
+ "sync"
+ "time"
+)
+
+type DatabaseRoot struct {
+ Stickers []DatabaseSticker `json:"stickers"`
+}
+
+type DatabaseSticker struct {
+ Created time.Time `json:"created"` // Sticker Created
+ UserAddress string `json:"user_address"` // User IP Address (For Manual Bans)
+ UserName string `json:"user_name"` // User Name
+ UserURL string `json:"user_url"` // User URL (Optional)
+ Message string `json:"message"` // Sticker Message
+ Visible bool `json:"visible"` // Sticker Visible?
+ OffsetX int `json:"offset_x"` // Placement X
+ OffsetY int `json:"offset_y"` // Placement Y
+ ImageScale float64 `json:"image_scale"` // Image Scale
+ ImageHeight int `json:"image_height"` // Image Height
+ ImageWidth int `json:"image_width"` // Image Width
+ ImageType ImageType `json:"image_type"` // Original Image File Type
+ ImageHash string `json:"image_hash"` // Original Image File Hash
+}
+
+var (
+ Database DatabaseRoot
+ DatabaseMtx sync.RWMutex
+)
+
+func SetupDatabase(stop context.Context, await *sync.WaitGroup) {
+ t := time.Now()
+ p := path.Join(DATA_DIRECTORY, "database.json")
+
+ // Decode File
+ b, err := os.ReadFile(p)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ log.Fatalln("[db] Read Database Error:", err)
+ }
+ Database.Stickers = make([]DatabaseSticker, 0)
+ }
+ if err == nil {
+ if err := json.Unmarshal(b, &Database); err != nil {
+ log.Fatalln("[db] Parse Database Error:", err)
+ }
+ }
+
+ // Shutdown Logic
+ await.Add(1)
+ go func() {
+ defer await.Done()
+ <-stop.Done()
+
+ // Write Database To Disk
+ b, err := json.MarshalIndent(&Database, "", " ")
+ if err != nil {
+ log.Fatalln("[db] Cannot Marshal Database:", err)
+ }
+ if err := os.WriteFile(p, b, FILE_MODE); err != nil {
+ log.Fatalln("[db] Cannot Write Database:", err)
+ }
+ log.Println("[db] Database Saved")
+ }()
+
+ log.Println("[db] Ready in", time.Since(t))
+}
diff --git a/env/Model.go b/env/Model.go
new file mode 100644
index 0000000..6830c45
--- /dev/null
+++ b/env/Model.go
@@ -0,0 +1,101 @@
+package env
+
+import (
+ "context"
+ "image"
+ "log"
+ "sync"
+ "time"
+
+ tf "github.com/galeone/tensorflow/tensorflow/go"
+ "golang.org/x/image/draw"
+)
+
+const (
+ MODEL_TRESHOLD = 0.7 // Threshold before an image is considered inappropriate
+ MODEL_SIZE = 224 // Model Size
+)
+
+var nsfwModel *tf.SavedModel
+
+func SetupModel(stop context.Context, await *sync.WaitGroup) {
+ t := time.Now()
+
+ // Read Model from Disk
+ model, err := tf.LoadSavedModel("resources/model", []string{"serve"}, nil)
+ if err != nil {
+ log.Fatalf("[model] Unable to Load Model (%s)\n", err)
+ }
+ nsfwModel = model
+
+ // Test Model using Dummy Tensor
+ dummy, _ := tf.NewTensor([1][MODEL_SIZE][MODEL_SIZE][3]float32{})
+ if _, err := ModelClassifyTensor(dummy); err != nil {
+ log.Fatalf("[model] Failed to Initialize Model (%s)\n", err)
+ }
+
+ // Shutdown Logic
+ await.Add(1)
+ go func() {
+ defer await.Done()
+ <-stop.Done()
+ nsfwModel.Session.Close()
+ log.Println("[model] Model Closed")
+ }()
+
+ log.Printf("[model] Model Ready (%s)\n", time.Since(t))
+}
+
+// Cast Predictions on a Tensor using the NSFW Model
+func ModelClassifyTensor(tensor *tf.Tensor) ([]float32, error) {
+ results, err := nsfwModel.Session.Run(
+ map[tf.Output]*tf.Tensor{
+ nsfwModel.Graph.Operation("serving_default_input").Output(0): tensor,
+ },
+ []tf.Output{
+ nsfwModel.Graph.Operation("StatefulPartitionedCall").Output(0),
+ },
+ []*tf.Operation{},
+ )
+ if err != nil {
+ return []float32{}, err
+ }
+ // cursed...
+ return results[0].Value().([][]float32)[0], err
+}
+
+// Classify an Image returning true if it's considered safe
+func ModelClassifyImage(someImage image.Image) (bool, error) {
+
+ // Resize Image to Usable Size
+ resized := image.NewRGBA(image.Rect(0, 0, MODEL_SIZE, MODEL_SIZE))
+ draw.NearestNeighbor.Scale(resized, resized.Rect, someImage, someImage.Bounds(), draw.Over, nil)
+
+ // Convert Pixel Data into Normalized Floats
+ var tensorCap = MODEL_SIZE * MODEL_SIZE * 3
+ var tensorData = make([]float32, 0, tensorCap)
+ var tensorShape = []int64{1, MODEL_SIZE, MODEL_SIZE, 3}
+ for x := 0; x < MODEL_SIZE; x++ {
+ for y := 0; y < MODEL_SIZE; y++ {
+ r, g, b, _ := resized.At(x, y).RGBA()
+ tensorData = append(tensorData, float32(r)/65535, float32(g)/65535, float32(b)/65535)
+ }
+ }
+
+ // Create Tensor, reshape it, then classify
+ tensor, err := tf.NewTensor(tensorData)
+ if err != nil {
+ return false, err
+ }
+ if err := tensor.Reshape(tensorShape); err != nil {
+ return false, err
+ }
+ results, err := ModelClassifyTensor(tensor)
+ if err != nil {
+ return false, err
+ }
+
+ // Calculate How Inappropriate this Image is
+ // Drawing[0], Hentai[1], Neutral[2], Porn[3], Sexy[4]
+ return (results[1] + results[3] + (results[4] * 0.9)) < MODEL_TRESHOLD, nil
+}
diff --git a/env/Stickerboard.go b/env/Stickerboard.go
new file mode 100644
index 0000000..f82e950
--- /dev/null
+++ b/env/Stickerboard.go
@@ -0,0 +1,264 @@
+package env
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "golang.org/x/image/draw"
+ "golang.org/x/image/webp"
+)
+
+const (
+ CANVAS_WIDTH = 854 // Canvas Width
+ CANVAS_HEIGHT = 480 // Canvas Height
+ CANVAS_STICKER_MAX_HEIGHT = 240 // Canvas Max Sticker Height in Pixels
+ CANVAS_FRAMES = 100
+ CANVAS_FPS = 20
+ CANVAS_DELAY = 100 / CANVAS_FPS
+)
+
+var (
+ StickerboardReady atomic.Bool
+ StickerboardPath = path.Join(DATA_DIRECTORY, STICKERBOARD_FILENAME)
+ StickerboardBack *image.RGBA
+ StickerboardMtx sync.RWMutex
+ Stickerboard []byte
+)
+
+func init() {
+ // Initialize Background to Black to Prevent Coalescing
+ base := image.NewRGBA(image.Rect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT))
+ for x := 0; x < CANVAS_WIDTH; x++ {
+ for y := 0; y < CANVAS_HEIGHT; y++ {
+ base.Set(x, y, color.Black)
+ }
+ }
+ StickerboardBack = base
+
+ // Load Custom Background (if any)
+ p := path.Join(DATA_DIRECTORY, "background.png")
+ if f, err := os.Open(p); err == nil {
+ if o, err := png.Decode(f); err == nil {
+ b := base.Bounds()
+ draw.CatmullRom.Scale(base, b.Bounds(), o, o.Bounds(), draw.Over, nil)
+ }
+ f.Close()
+ }
+}
+
+func stickerboardCopy() {
+ b, err := os.ReadFile(StickerboardPath)
+ if err != nil {
+ log.Println("[stickerboard] Read Image Error:", err)
+ return
+ }
+ StickerboardMtx.Lock()
+ Stickerboard = b
+ StickerboardReady.Store(true)
+ StickerboardMtx.Unlock()
+}
+
+func StickerboardRender() (int, error) {
+ t := time.Now()
+
+ // Mass Decode and Resizing of all Stickers
+ type DecodedSticker struct {
+ Position image.Rectangle
+ Frames []*image.RGBA
+ Delays []int
+ }
+ stickers := make([]DecodedSticker, len(Database.Stickers))
+
+ DatabaseMtx.RLock()
+ if err := Multithread(len(stickers), func(i int) error {
+
+ // Read Sticker from Disk
+ info := &Database.Stickers[i]
+ path := path.Join(DATA_DIRECTORY, info.ImageHash)
+ reader, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+ var images = make([]image.Image, 1)
+ var delays = make([]int, 1)
+
+ // Decode Sticker Frames
+ var decodeGIF *gif.GIF
+ var decodeImage image.Image
+ var decodeError error
+ switch info.ImageType {
+ case IMAGE_GIF:
+ decodeGIF, decodeError = gif.DecodeAll(reader)
+ case IMAGE_WEBP:
+ decodeImage, decodeError = webp.Decode(reader)
+ case IMAGE_JPEG:
+ decodeImage, decodeError = jpeg.Decode(reader)
+ case IMAGE_PNG:
+ decodeImage, decodeError = png.Decode(reader)
+ default:
+ return fmt.Errorf("decoder available")
+ }
+ if decodeError != nil {
+ return err
+ }
+ if info.ImageType == IMAGE_GIF {
+
+ // This section here properly layers GIF frames
+
+ delays = decodeGIF.Delay
+ images = make([]image.Image, len(decodeGIF.Image))
+
+ var imageBase = image.NewRGBA(image.Rect(0, 0, decodeGIF.Config.Width, decodeGIF.Config.Height))
+ var imagePrev *image.RGBA
+ var disposal = byte(gif.DisposalNone)
+
+ for i, frame := range decodeGIF.Image {
+ if disposal == gif.DisposalPrevious {
+ imagePrev = image.NewRGBA(imageBase.Bounds())
+ copy(imageBase.Pix, imagePrev.Pix)
+ }
+ if i > 0 {
+ switch disposal {
+ case gif.DisposalBackground:
+ draw.Draw(imageBase, decodeGIF.Image[i-1].Bounds(), image.Transparent, image.Point{}, draw.Src)
+ case gif.DisposalPrevious:
+ if imagePrev != nil {
+ draw.Draw(imageBase, imageBase.Bounds(), imagePrev, image.Point{}, draw.Src)
+ }
+ }
+ }
+ // Save Composited Frame
+ draw.Draw(imageBase, frame.Bounds(), frame, frame.Bounds().Min, draw.Over)
+ imageCopy := image.NewRGBA(imageBase.Bounds())
+ copy(imageCopy.Pix, imageBase.Pix)
+
+ images[i] = imageCopy
+ disposal = decodeGIF.Disposal[i]
+ }
+
+ } else {
+ // Copy Static Frame
+ images[0] = decodeImage
+ }
+
+ // Resize Decoded Frames
+ var (
+ stickerFrames = make([]*image.RGBA, len(images))
+ stickerPosition image.Rectangle
+ stickerBounds = images[0].Bounds()
+ stickerWidth = int(float64(stickerBounds.Dx()) * info.ImageScale)
+ stickerHeight = int(float64(stickerBounds.Dy()) * info.ImageScale)
+ )
+ for j := range images {
+ // Nice and Smooth Scaling
+ source := images[j]
+ scaled := image.NewRGBA(image.Rect(0, 0, stickerWidth, stickerHeight))
+ draw.CatmullRom.Scale(scaled, scaled.Bounds(), source, source.Bounds(), draw.Over, nil)
+
+ // Invert Y position because browsers placment origin is bottom-left but server is top-left
+ y := CANVAS_HEIGHT - info.OffsetY - stickerHeight
+ stickerPosition = image.Rect(info.OffsetX, y, info.OffsetX+stickerWidth, y+stickerHeight)
+ stickerFrames[j] = scaled
+ }
+
+ // Store Resized Frame
+ stickers[i] = DecodedSticker{
+ Frames: stickerFrames,
+ Delays: delays,
+ Position: stickerPosition,
+ }
+ return nil
+
+ }); err != nil {
+ DatabaseMtx.RUnlock()
+ return 0, err
+ }
+ DatabaseMtx.RUnlock()
+
+ // Startup Encoder
+ var outputLogs bytes.Buffer
+ cmd := exec.Command(
+ "ffmpeg", "-y",
+ "-threads", fmt.Sprint(runtime.NumCPU()),
+ "-f", "rawvideo",
+ "-pix_fmt", "rgba",
+ "-s", fmt.Sprintf("%dx%d", CANVAS_WIDTH, CANVAS_HEIGHT),
+ "-framerate", fmt.Sprint(CANVAS_FPS),
+ "-i", "pipe:0",
+ "-vcodec", "libwebp",
+ "-compression_level", "4",
+ "-q:v", "75",
+ "-loop", "0",
+ StickerboardPath,
+ )
+ cmdStdin, err := cmd.StdinPipe()
+ if err != nil {
+ return 0, err
+ }
+ cmd.Stdout = &outputLogs
+ cmd.Stderr = &outputLogs
+ if err := cmd.Start(); err != nil {
+ return 0, err
+ }
+
+ // Generate Frames
+ for i := 0; i < CANVAS_FRAMES; i++ {
+
+ // Generate Frame
+ canvas := image.NewRGBA(StickerboardBack.Rect)
+ copy(canvas.Pix, StickerboardBack.Pix)
+ for j := range stickers {
+ decode := &stickers[j]
+ index := 0
+ if len(decode.Frames) > 1 {
+ offset := 0
+ for {
+ idx := index % len(decode.Frames)
+ offset += decode.Delays[idx]
+ if offset > (i * CANVAS_DELAY) {
+ index = idx
+ break
+ }
+ index++
+ }
+ }
+ draw.Draw(canvas, decode.Position, decode.Frames[index], image.Point{}, draw.Over)
+ }
+
+ // Submit Encoder
+ for y := 0; y < canvas.Rect.Dy(); y++ {
+ start := y * canvas.Stride
+ end := start + canvas.Rect.Dx()*4
+ cmdStdin.Write(canvas.Pix[start:end])
+ }
+ }
+
+ // Await Encoding
+ cmdStdin.Close()
+ if err := cmd.Wait(); err != nil {
+ exitCode := -1
+ if cmd.ProcessState != nil {
+ exitCode = cmd.ProcessState.ExitCode()
+ }
+ log.Println("[sticker] FFMPEG Exited With Code %d\n%s\n", exitCode, outputLogs.String())
+ return 0, err
+ }
+
+ log.Printf("[sticker] Rendered %d Images in %s", len(stickers), time.Since(t))
+ stickerboardCopy()
+ return len(stickers), nil
+}
diff --git a/env/Utils.go b/env/Utils.go
new file mode 100644
index 0000000..4944513
--- /dev/null
+++ b/env/Utils.go
@@ -0,0 +1,84 @@
+package env
+
+import (
+ "runtime"
+ "sync"
+ "sync/atomic"
+)
+
+type ImageType string
+
+const (
+ IMAGE_OTHER ImageType = "UNKNOWN"
+ IMAGE_WEBP ImageType = "WEBP"
+ IMAGE_JPEG ImageType = "JPG"
+ IMAGE_PNG ImageType = "PNG"
+ IMAGE_GIF ImageType = "GIF"
+)
+
+// Classify the contents of a file based on it's starting bytes
+// https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files)
+func ImageSniffType(d []byte) ImageType {
+ switch {
+ case len(d) > 3 && // JPEG
+ d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF:
+ return IMAGE_JPEG
+
+ case len(d) > 8 && // PNG
+ d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 &&
+ d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A:
+ return IMAGE_PNG
+
+ case len(d) > 4 && // GIF
+ d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38:
+ return IMAGE_GIF
+
+ case len(d) > 12 && // WEBP
+ d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
+ d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50:
+ return IMAGE_WEBP
+
+ default:
+ return IMAGE_OTHER
+ }
+}
+
+// Easily spread a workload across all available threads, returning the error if any
+func Multithread(jobCount int, handler func(i int) error) error {
+ threads := runtime.NumCPU()
+ channel := make(chan int, jobCount)
+ var wait sync.WaitGroup
+ var fail atomic.Value
+
+ // Startup Goroutines
+ for workerId := 0; workerId < threads; workerId++ {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ for i := range channel {
+ // Exit early if an error has ocurred
+ if fail.Load() != nil {
+ return
+ }
+ // Run Handler and store it's error (if any)
+ if err := handler(i); err != nil {
+ fail.Store(err)
+ return
+ }
+ }
+ }()
+ }
+
+ // Queue and Complete Work Across all Threads
+ for i := 0; i < jobCount; i++ {
+ channel <- i
+ }
+ close(channel) // close or wait forever
+ wait.Wait()
+
+ // Return Error (if any)
+ if v := fail.Load(); v != nil {
+ return v.(error)
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..14fb3d2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module bakonpancakz/stickerboard
+
+go 1.23.4
+
+require (
+ github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe
+ github.com/joho/godotenv v1.5.1
+ golang.org/x/image v0.27.0
+)
+
+require google.golang.org/protobuf v1.36.5 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d0a07c6
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe h1:7yELf1NFEwECpXMGowkoftcInMlVtLTCdwWLmxKgzNM=
+github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe/go.mod h1:TelZuq26kz2jysARBwOrTv16629hyUsHmIoj54QqyFo=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
+golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..444f679
--- /dev/null
+++ b/main.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+
+ "bakonpancakz/stickerboard/env"
+ "bakonpancakz/stickerboard/routes"
+
+ _ "github.com/joho/godotenv/autoload"
+)
+
+func main() {
+ // Startup Services
+ var stopCtx, stop = context.WithCancel(context.Background())
+ var stopWg sync.WaitGroup
+ env.SetupDatabase(stopCtx, &stopWg)
+ env.SetupModel(stopCtx, &stopWg)
+ go SetupHTTP(stopCtx, &stopWg)
+ go env.StickerboardRender()
+
+ // Await Shutdown Signal
+ cancel := make(chan os.Signal, 1)
+ signal.Notify(cancel, syscall.SIGINT, syscall.SIGTERM)
+ <-cancel
+ stop()
+
+ // Begin Shutdown Process
+ timeout, finish := context.WithTimeout(context.Background(), time.Minute)
+ defer finish()
+ go func() {
+ <-timeout.Done()
+ if timeout.Err() == context.DeadlineExceeded {
+ log.Fatalln("[main] Cleanup timeout! Exiting now.")
+ }
+ }()
+ stopWg.Wait()
+ log.Println("[main] All done, bye bye!")
+ os.Exit(0)
+}
+
+func SetupHTTP(stop context.Context, await *sync.WaitGroup) {
+
+ r := http.NewServeMux()
+ r.HandleFunc("/", routes.GET_Index)
+ r.HandleFunc("/stickers", routes.POST_Stickers)
+ r.HandleFunc("/assets/{filename}", routes.GET_Assets_Filename)
+ svr := http.Server{
+ Handler: r,
+ Addr: env.HTTP_ADDRESS,
+ TLSConfig: env.HTTP_TLS,
+ MaxHeaderBytes: 4096,
+ IdleTimeout: 5 * time.Second,
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ }
+
+ // Shutdown Logic
+ await.Add(1)
+ go func() {
+ defer await.Done()
+ <-stop.Done()
+ svr.Shutdown(context.Background())
+ log.Println("[http] Cleaned up HTTP")
+ }()
+
+ // Server Startup
+ var err error
+ if env.TLS_ENABLED {
+ log.Printf("[http] Bound HTTPS - %s\n", svr.Addr)
+ err = svr.ListenAndServeTLS("", "")
+ } else {
+ log.Printf("[http] Bound HTTP - %s\n", svr.Addr)
+ err = svr.ListenAndServe()
+ }
+ if err != http.ErrServerClosed {
+ log.Fatalln("[http] Listen Error:", err)
+ }
+}
diff --git a/resources/index.html b/resources/index.html
new file mode 100644
index 0000000..ead56f3
--- /dev/null
+++ b/resources/index.html
@@ -0,0 +1,146 @@
+
+
+
+
+ Stickerboard - pancakz
+
+
+
+
+
+
+
+
+
+ Select the image you wish to share.
+ It must be in either GIF, JPEG, or PNG format,
+ up to 16 Megabytes in size, and up to 2048 pixels in dimensions.
+
+
+
+
+
+
+
Placement
+
+ You can position your sticker by either clicking anywhere
+ on the canvas
+ or by using the inputs boxes below.
+
+
+
X:
+
+
Y:
+
+
Scale:
+
+
+
+
+
+
+
Profile
+
+ Leave these boxes blank to remain anonymous. You can
+ include a URL to your personal homepage or social media
+ if you like.
+
+
+
+
+
+
+
Comment
+
+ Leave a comment for other users to see.
+ Inappropriate comments will be censored.
+