26 Commits

Author SHA1 Message Date
1bf6bf9033 Show images from urls 2024-08-08 12:05:40 -04:00
e21cc83a5f add stock.svg 2024-08-07 19:42:43 -04:00
298fed011d Implement photo selector 2024-08-07 18:07:41 -04:00
12befa0ac3 Implement LoadPhotos and DownloadPhoto 2024-08-06 23:14:06 -04:00
5fee186dcb Add a dialog for showing notices 2024-08-03 01:47:22 -04:00
bf6bdf9b72 frontend for get-albums 2024-08-03 00:38:54 -04:00
fa1c8e6963 implement get-albums 2024-08-02 23:39:49 -04:00
9200bd16db Minor refactor
* Move namegen to seperate file
* Simplify Makefile
2023-09-19 22:59:47 -04:00
acc9e87f2c Merge pull request 'Cleanup for 1.0' (#18) from cleanup into main
Reviewed-on: #18

Fixes #10
2023-09-14 15:13:32 -04:00
5c8f6bf443 remove dead code and comments 2023-09-14 15:12:28 -04:00
2b1253e416 Replace croppie.js with croppie.min.js 2023-09-14 15:12:28 -04:00
445ec79a7a Remove unwanted files 2023-09-14 15:12:28 -04:00
a6abe54dbc Remove live reload js when not in dev mode
Fixes #16
2023-09-14 15:12:28 -04:00
ab636df83d Highlight selected template 2023-09-14 14:57:33 -04:00
61a8d4ca5c Add anyhttp support (#17)
Reviewed-on: #17
Fixes #11
2023-09-11 21:16:10 -04:00
6854a3e1f1 Merge pull request 'Implement selecting template for collage' (#14) from template-chooser into main
Reviewed-on: #14
2023-09-06 23:59:07 -04:00
b7fe2e8192 cleanup 2023-09-06 23:58:21 -04:00
8034acb504 Implement selecting template for collage 2023-09-06 23:44:29 -04:00
8a56639920 Add jsdoc typehints 2023-09-06 18:56:02 -04:00
00bc166148 add page size select 2023-09-05 18:42:47 -04:00
9dceae06a7 Move css to seperate file and add no-cache for dev assets 2023-09-05 09:04:54 -04:00
a8f9d87b47 css cleanup 2023-09-01 23:12:52 -04:00
1662ff226b minor refactor 2023-09-01 15:13:54 -04:00
faa413c9b1 Snap button generates collage and shows link
fixes #3
2023-08-31 23:31:51 -04:00
1c742809a1 Serve web files in go 2023-08-31 20:27:43 -04:00
e1e6e3650a Add post target to create collage
fixes #1
2023-08-28 23:35:32 -04:00
24 changed files with 1018 additions and 2034 deletions

View File

@ -1,7 +1,10 @@
# livereload: github.com/omeid/go-livereload/cmd/livereload@v0.0.0-20180903043807-18d58b752b26
livereload:
livereload . &
go run github.com/omeid/go-livereload/cmd/livereload@latest web | ts
server:
python3 -m http.server 8082 &
devserver:
go run . --dev --images-dir w.tmp/images --collages-dir w.tmp
update_croppie:
curl -Lo web/croppie.min.js https://github.com/Foliotek/Croppie/raw/v2.6.5/croppie.min.js
curl -Lo web/croppie.css https://github.com/Foliotek/Croppie/raw/v2.6.5/croppie.css

View File

@ -1,56 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"go.balki.me/collage-maker/collage"
)
func main() {
req := collage.Request{}
/*
reqStr := []byte(`
{
"background_image": "",
"aspect": { "width": 4224, "height": 3264 },
"dimension": { "width": 1187, "height": 848 },
"photos": [
{
"image": "img1.jpg",
"crop": {
"start": { "x": 419, "y": 667 },
"end": { "x": 2707, "y": 3389 }
},
"frame": {
"start": { "x": 0, "y": 0 },
"end": { "x": 712, "y": 848 }
}
},
{
"image": "img2.jpg",
"crop": {
"start": { "x": 331, "y": 44 },
"end": { "x": 1132, "y": 1468 }
},
"frame": {
"start": { "x": 712, "y": 0 },
"end": { "x": 1187, "y": 848 }
}
}
]
}
`)
*/
// {"background_image":"","aspect":{"width":4224,"height":3264},"dimension":{"width":1187,"height":848},"photos":[{"image":"img1.jpg","crop":{"start":{"x":528,"y":3},"end":{"x":2696,"y":2583}},"frame":{"start":{"x":0,"y":0},"end":{"x":712,"y":848}}},{"image":"img2.jpg","crop":{"start":{"x":410,"y":0},"end":{"x":1014,"y":1074}},"frame":{"start":{"x":712,"y":0},"end":{"x":1187,"y":848}}}]}
reqStr := []byte(`
{"background_image":"","aspect":{"width":4224,"height":3264},"dimension":{"width":1097,"height":848},"photos":[{"image":"img1.jpg","crop":{"start":{"x":448,"y":595},"end":{"x":2721,"y":3560}},"frame":{"start":{"x":0,"y":0},"end":{"x":649,"y":848}}},{"image":"img2.jpg","crop":{"start":{"x":418,"y":1},"end":{"x":1022,"y":1180}},"frame":{"start":{"x":665,"y":0},"end":{"x":1098,"y":848}}}]}
`)
err := json.Unmarshal(reqStr, &req)
fmt.Println(err)
out, err := os.Create("./collage.jpg")
fmt.Println(err)
err = collage.Make(req, os.DirFS("."), out)
fmt.Println(err)
}

View File

@ -48,7 +48,7 @@ type Request struct {
Photos []Photo `json:"photos"`
}
func Make(req Request, source fs.FS, output io.Writer) error {
func Make(req *Request, source fs.FS, output io.Writer) error {
rec := image.Rect(0, 0, int(req.Aspect.Width), int(req.Aspect.Height))
canvas := image.NewRGBA64(rec)
white := color.RGBA{255, 255, 255, 255}

View File

@ -67,7 +67,7 @@ func TestMake(t *testing.T) {
t.Fatalf("failed to create ouput image file %v", err)
}
err = Make(req, testDataFS, out)
err = Make(&req, testDataFS, out)
if err != nil {
t.Fatalf("failed to make collage %v", err)
}
@ -104,17 +104,6 @@ func TestCrop(t *testing.T) {
if string(refImgPrefix) != croppedImgPrefix {
t.Fatalf("Cropped image is not the same as reference image")
}
// SavePrefix(cropped)
// expectedImage, err := GetImage(testDataFS, "test_output.jpg")
// if err != nil {
// t.Fatalf("failed to get reference crop image %v", err)
// }
// fmt.Printf("%v\n", cropped)
// fmt.Printf("%v\n", expectedImage)
// if fmt.Sprintf("%#v", cropped) != fmt.Sprintf("%#v", expectedImage) {
// t.Fatalf("Cropped image is not the same as reference image")
// }
// SaveImage(cropped)
}
// Save first 1000 bytes of string representation to compare against reference

1625
croppie.js

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
hello world

4
go.mod
View File

@ -1,9 +1,9 @@
module go.balki.me/collage-maker
go 1.21
go 1.22
require (
github.com/oliamb/cutter v0.2.2
go.balki.me/anyhttp v0.3.0
go.oneofone.dev/resize v1.0.1
)

4
go.sum
View File

@ -1,6 +1,6 @@
github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k=
github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.balki.me/anyhttp v0.3.0 h1:WtBQ0rnkg567sX/O4ij/+qBbdCIUt5VURSe718sITBY=
go.balki.me/anyhttp v0.3.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
go.oneofone.dev/resize v1.0.1 h1:HjpVar/4pxMGrjO44ThaMX1Q5UOBw0KxzbxxRDZPQuA=
go.oneofone.dev/resize v1.0.1/go.mod h1:zGFmn7q4EUZVlnDmxqf+b0mWpxsTt0MH2yx6ng8tpq0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@ -1,86 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="croppie.css" />
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<script src="http://localhost:35729/livereload.js"></script>
<script src="croppie.js"></script>
<script src="index.js"></script>
<!--
width: 3264px;
height: 4224px;
width: 8.5in;
height: 11in;
-->
<style>
.image-surface {
overflow: hidden;
margin: auto;
background-color: lightgreen;
}
.imagebox {
grid-area: image;
display:flex;
align-items: center;
justify-content: center;
padding: 50px;
flex: 70%;
}
.container {
display: flex;
background-color: lightyellow;
height: calc(100vh - 20px);
}
.controls {
background-color: lightgrey;
display: flex;
grid-area: controls;
align-items: center;
justify-content: center;
flex: 30%;
}
.showbuton {
font-size: 2rem;
}
.letter-portrait {
height: 100%;
aspect-ratio: 85 / 110;
}
.letter-portrait .collage-img {
height: 50%;
}
.letter-landscape-2 {
display:flex;
// width: 80vh;
height: 100%;
// margin: auto;
aspect-ratio: 110 / 85;
// aspect-ratio: 7 / 5;
gap: 1rem;
}
.letter-landscape-2 .img1 {
flex: 60%;
}
.letter-landscape-2 .img2 {
flex: 40%;
}
</style>
</head>
<body onload="main()">
<div class="container">
<div class="controls">
<button class="showbuton" onClick="snap()">Snap Collage</button>
</div>
<div class="imagebox">
<div id="collage" class="image-surface letter-landscape-2">
<div class="collage-img img1" data-collage-image-url="img1.jpg">
<!-- <img src="img1.jpg"> -->
</div>
<div class="collage-img img2" data-collage-image-url="img2.jpg">
<!-- <img src="img2.jpg"> -->
</div>
</div>
</div>
</div>
</body>
</html>

107
index.js
View File

@ -1,107 +0,0 @@
function main() {
initCollage("collage")
}
function makeCroppieElem(elem, imgUrl) {
cpie = new Croppie(elem, {
viewport: {
width: elem.clientWidth,
height: elem.clientHeight,
type: 'square'
},
showZoomer: false,
});
cpie.bind({
url: imgUrl
});
return cpie
}
// collage state
var collageDivId;
var crops = [];
function initCollage(divId) {
collageDivId = divId
const collageDiv = document.getElementById(collageDivId)
for(elem of collageDiv.getElementsByClassName("collage-img")) {
const cpie = makeCroppieElem(elem, elem.dataset.collageImageUrl)
const lastLen = crops.push(cpie)
elem.dataset.collageCropieIndex = lastLen - 1
}
}
function showCrop() {
for(cpie of crops) {
console.log(cpie.get())
console.log(cpie.element.clientWidth)
console.log(cpie.element.clientHeight)
}
}
async function ip() {
const resp = await fetch("dummy.txt")
console.log(await resp.text())
}
function snap() {
const collageDiv = document.getElementById(collageDivId)
col = collageDiv.offsetLeft;
cot = collageDiv.offsetTop;
console.log("----------------------")
req = {
background_image: "",
aspect: {
width: 528 * 4 * 2,
height: 816 * 4,
},
dimension: {
width: collageDiv.clientWidth,
height: collageDiv.clientHeight,
},
photos: [],
};
for(elem of collageDiv.getElementsByClassName("collage-img")) {
const cpie = crops[elem.dataset.collageCropieIndex]
// console.log(cpie.get().points)
// console.log(elem.offsetLeft - col)
// console.log(elem.offsetTop - cot)
// console.log(elem.clientWidth)
// console.log(elem.clientHeight)
const fsx = elem.offsetLeft - col
const fsy = elem.offsetTop - cot
const [sx, sy, ex, ey] = cpie.get().points;
const photo = {
image: elem.dataset.collageImageUrl,
crop: {
start: {
x: parseInt(sx),
y: parseInt(sy),
},
end: {
x: parseInt(ex),
y: parseInt(ey),
},
},
frame: {
start: {
x: fsx,
y: fsy,
},
end: {
x: fsx + elem.clientWidth,
y: fsy + elem.clientHeight,
},
},
};
req.photos.push(photo)
}
console.log(JSON.stringify(req));
ip()
}

374
main.go
View File

@ -1,99 +1,327 @@
package main
import (
"bytes"
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"image"
"image/draw"
"image/jpeg"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"regexp"
"time"
"github.com/oliamb/cutter"
"go.oneofone.dev/resize"
"log/slog"
"go.balki.me/anyhttp"
"go.balki.me/anyhttp/idle"
"go.balki.me/collage-maker/collage"
)
/**
"431"
"697"
"2514"
"2047"
zoom: 0.392
816
528
-------------
"153"
"9"
"1331"
"772"
zoom: 0.6949
816
528
*/
var (
imagesDir string
collageDir string
photosDir string
devMode bool
collageNameGen *nameGen
imagesDirFs fs.FS
listenAddr string
photoPrismURL *url.URL
photoPrismToken string
//go:embed web
webFS embed.FS
)
func main() {
const width = 816 * 4
const height = 528 * 4
imgFile1, err := os.Open("img1.jpg")
if err != nil {
fmt.Println(err)
}
imgFile2, err := os.Open("img2.jpg")
if err != nil {
fmt.Println(err)
}
img1, _, err := image.Decode(imgFile1)
if err != nil {
fmt.Println(err)
}
img2, _, err := image.Decode(imgFile2)
if err != nil {
fmt.Println(err)
}
var ppURL string
img1, err = cutter.Crop(img1, cutter.Config{
Width: 2514 - 431,
Height: 2047 - 697,
Anchor: image.Point{431, 697},
Mode: cutter.TopLeft, // optional, default value
})
flag.StringVar(&imagesDir, "images-dir", "images", "Sets the images dir")
flag.StringVar(&collageDir, "collages-dir", "collages", "Sets the collages dir")
flag.StringVar(&photosDir, "photos-dir", "photos", "Cache directory for downloaded photos")
flag.BoolVar(&devMode, "dev", false, "Serve local assets during development")
flag.StringVar(&listenAddr, "addr", "127.0.0.1:8767", "Web listen address, see https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax")
flag.StringVar(&ppURL, "pp-url", "", "Base url for photoprism")
flag.StringVar(&photoPrismToken, "pp-token", "", "API token for photoprism")
flag.Parse()
photoPrismURL = func() *url.URL {
photoPrismURL, err := url.Parse(ppURL)
if err != nil {
panic(err)
}
return photoPrismURL
}()
collageNameGen = NewNameGen()
imagesDirFs = os.DirFS(imagesDir)
imagesURLPath := "images"
collagesPath := "collages"
photosPath := "photos"
img1 = resize.Resize(width, height, img1, resize.Lanczos3)
addFileServer := func(path, dir string) {
httpFileServer := http.FileServer(http.Dir(dir))
http.Handle("/"+path+"/", http.StripPrefix("/"+path, httpFileServer))
}
img2, err = cutter.Crop(img2, cutter.Config{
Width: 1331 - 153,
Height: 772 - 9,
Anchor: image.Point{153, 9},
Mode: cutter.TopLeft, // optional, default value
addFileServer(imagesURLPath, imagesDir)
addFileServer(collagesPath, collageDir)
addFileServer(photosPath, photosDir)
if devMode {
httpFileServer := http.FileServer(http.Dir("web"))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", "no-cache")
httpFileServer.ServeHTTP(w, r)
})
} else {
indexModTime := time.Now()
indexHTML := func() io.ReadSeeker {
indexHTMLContent, err := webFS.ReadFile("web/index.html")
if err != nil {
panic(err)
}
img2 = resize.Resize(width, height, img2, resize.Lanczos3)
//starting position of the second image (bottom left)
//sp2 := image.Point{img1.Bounds().Dx(), 0}
//new rectangle for the second image
//r2 := image.Rectangle{sp2, sp2.Add(img2.Bounds().Size())}
//rectangle for the big image
r := image.Rectangle{image.Point{0, 0}, image.Point{width, height + height}}
r2 := image.Rectangle{image.Point{0, height + 1}, image.Point{width, height + height}}
rgba := image.NewRGBA(r)
draw.Draw(rgba, img1.Bounds(), img1, image.Point{0, 0}, draw.Src)
draw.Draw(rgba, r2, img2, image.Point{0, 0}, draw.Src)
out, err := os.Create("./output.jpg")
devOnlyRegex := regexp.MustCompile("\n[^\n]*<!-- DEVONLY[^\n]*")
return bytes.NewReader(devOnlyRegex.ReplaceAllLiteral(indexHTMLContent, nil))
}()
httpFileServer := func() http.Handler {
webrootFs, err := fs.Sub(webFS, "web")
if err != nil {
fmt.Println(err)
panic(err)
}
return http.FileServer(http.FS(webrootFs))
}()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.ServeContent(w, r, "index.html", indexModTime, indexHTML)
} else {
httpFileServer.ServeHTTP(w, r)
}
})
}
var opt jpeg.Options
opt.Quality = 80
http.HandleFunc("/make-collage", func(w http.ResponseWriter, r *http.Request) {
collageReq := collage.Request{}
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("failed to read request body", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := json.Unmarshal(body, &collageReq); err != nil {
slog.Error("failed to unmarshal json request", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
collageFilePath, err := MakeCollage(&collageReq)
if err != nil {
slog.Error("failed to make collage", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write([]byte(collageFilePath)); err != nil {
slog.Error("Failed to write collageFile", "error", err)
}
})
jpeg.Encode(out, rgba, &opt)
http.HandleFunc("/get-albums", func(w http.ResponseWriter, r *http.Request) {
albums, err := GetAlbums()
if err != nil {
slog.Error("failed to get albums", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
jData, err := json.Marshal(albums)
if err != nil {
slog.Error("failed to marshal albums", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(jData)
})
http.HandleFunc("/load-photos/{albumID}", func(w http.ResponseWriter, r *http.Request) {
albumID := r.PathValue("albumID")
photos, err := LoadPhotos(albumID)
if err != nil {
slog.Error("failed to load photos", "error", err, "album", albumID)
w.WriteHeader(http.StatusInternalServerError)
return
}
photoURLs := []string{}
for _, photo := range photos {
photoURLs = append(photoURLs, fmt.Sprintf("/%s/%s.jpg", photosPath, photo.FileUID))
}
go func() {
for _, photo := range photos {
_, err := DownloadPhoto(&photo)
if err != nil {
slog.Error("failed to download photo", "error", err, "album", albumID, "photoID", photo.FileUID)
}
}
}()
jData, err := json.Marshal(photoURLs)
if err != nil {
slog.Error("failed to marshal photoURLs", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(jData)
})
addrType, server, done, err := anyhttp.Serve(listenAddr, idle.WrapHandler(nil))
if err != nil {
slog.Error("anyhttp Serve failed", "error", err)
}
if addrType == anyhttp.SystemdFD {
if err := idle.Wait(30 * time.Minute); err != nil {
slog.Error("Failed to wait for idler", "error", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) // Don't want any stuck connections
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("http server Shutdown failed", "error", err)
}
} else {
<-done
}
}
func MakeCollage(req *collage.Request) (string, error) {
collageFile := fmt.Sprintf("collage-%s.jpg", collageNameGen.Next())
out, err := os.Create(path.Join(collageDir, collageFile))
if err != nil {
return "", fmt.Errorf("failed to create collage output file, err: %w", err)
}
defer out.Close()
if err := collage.Make(req, imagesDirFs, out); err != nil {
return "", fmt.Errorf("failed to make collage, err: %w", err)
}
return collageFile, nil
}
type Album struct {
Title string `json:"Title"`
UID string `json:"UID"`
}
func GetAlbums() ([]Album, error) {
albumURL := func() string {
u := *photoPrismURL
u.Path = "/api/v1/albums"
v := url.Values{}
v.Add("count", "20")
v.Add("type", "album")
u.RawQuery = v.Encode()
return u.String()
}()
req, err := http.NewRequest("GET", albumURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
albums := []Album{}
err = json.Unmarshal(respBytes, &albums)
if err != nil {
return nil, err
}
return albums, nil
}
type Photo struct {
Hash string `json:"Hash"`
FileUID string `json:"FileUID"`
}
func LoadPhotos(albumID string) ([]Photo, error) {
loadPhotosURL := func() string {
u := *photoPrismURL
u.Path = "/api/v1/photos"
v := url.Values{}
v.Add("count", "50")
v.Add("s", albumID)
v.Add("merged", "true")
v.Add("video", "false")
u.RawQuery = v.Encode()
return u.String()
}()
req, err := http.NewRequest("GET", loadPhotosURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
photos := []Photo{}
err = json.Unmarshal(respBytes, &photos)
if err != nil {
return nil, err
}
return photos, nil
}
func DownloadPhoto(photo *Photo) (string, error) {
photoPath := path.Join(photosDir, fmt.Sprintf("%s.jpg", photo.FileUID))
_, err := os.Stat(photoPath)
if err == nil {
return photoPath, nil
} else if !os.IsNotExist(err) {
return "", err
}
out, err := os.Create(photoPath)
if err != nil {
return "", fmt.Errorf("failed to create collage output file, err: %w", err)
}
defer out.Close()
downloadPhotoURL := func() string {
u := *photoPrismURL
u.Path = fmt.Sprintf("/api/v1/dl/%s", photo.Hash)
return u.String()
}()
req, err := http.NewRequest("GET", downloadPhotoURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
_, err = io.Copy(out, resp.Body)
if err != nil {
return "", err
}
return photoPath, nil
}

9
main_test.go Normal file
View File

@ -0,0 +1,9 @@
package main
import "testing"
func TestNameGen(t *testing.T) {
g := NewNameGen()
t.Logf("next id: %s", g.Next())
t.Logf("next id: %s", g.Next())
}

35
namegen.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"crypto/rand"
"fmt"
"sync/atomic"
"time"
)
type nameGen struct {
prefix string
counter atomic.Uint64
}
func NewNameGen() *nameGen {
currentTime := time.Now().Unix()
randBytes := make([]byte, 8)
_, err := rand.Read(randBytes)
if err != nil {
panic(err)
}
alpha := []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
uniqRunID := ""
for _, b := range randBytes {
uniqRunID += string(alpha[int(b)%len(alpha)])
}
return &nameGen{
prefix: fmt.Sprintf("%d-%s", currentTime, uniqRunID),
counter: atomic.Uint64{},
}
}
func (n *nameGen) Next() string {
return fmt.Sprintf("%s-%d", n.prefix, n.counter.Add(1))
}

View File

@ -1,24 +0,0 @@
package main
type Resolution struct {
X uint
Y uint
}
// increased by factor 1.5
// https://www.adorama.com/alc/pixels-and-printing-size-matters/
var Letter = Resolution{
X: 3264,
Y: 4224,
}
var FiveXSeven = Resolution{
X: 2250,
Y: 3150,
}
var FourXSix = Resolution{
X: 1800,
Y: 2700,
}

View File

@ -1,36 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<style>
.paper {
width: 8.5in;
height: 11in;
overflow: hidden;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
background-color: gainsboro;
}
.img1, .img2 {
width: 7in;
height: 5in;
}
.img1 {
background-color: blue;
}
.img2 {
background-color: yellow;
}
</style>
</head>
<body>
<div class="paper">
<div class="img1"></div>
<div class="img2"></div>
</div>
</body>
</html>

47
web/choose.css Normal file
View File

@ -0,0 +1,47 @@
#album_photos {
display: flex;
gap: 1rem;
flex-wrap: wrap;
background-color: lightblue;
padding: 1rem;
}
#album_photos img {
width: 200px;
height: 200px;
}
#selected_photos {
display: flex;
gap: 1rem;
flex-wrap: wrap;
background-color: lightyellow;
padding: 1rem;
}
#selected_photos img {
width: 100px;
height: 100px;
}
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls {
background-color: lightgrey;
display: flex;
gap: 1rem;
padding: 1rem;
}
#album_selector {
width: 400px;
}
.current {
border: 5px solid;
border-color: red;
}

38
web/choose.html Normal file
View File

@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<!-- DEVONLY --> <script src="http://localhost:35729/livereload.js"></script>
<script src="choose.js" defer></script>
<link rel="stylesheet" href="choose.css" />
</head>
<body>
<dialog id="notice_dialog">
<p id="notice_p"></p>
<form><button type="submit" formmethod="dialog">X</button></form>
</dialog>
<div class="container">
<div class="controls">
<button id="load_albums_button">Load Albums</button>
<select id="album_selector" size="8"> </select>
<button id="load_photos_button">Load Photos</button>
<button id="make_collage_button">Make Collage</button>
</div>
<div id="selected_photos">
<div>1<img src="stock.svg" /> </div>
<div>2<img src="stock.svg" /> </div>
<div>3<img src="stock.svg" /> </div>
<div>4<img src="stock.svg" /> </div>
<div>5<img src="stock.svg" /> </div>
<div>6<img src="stock.svg" /> </div>
<div>7<img src="stock.svg" /> </div>
<div>8<img src="stock.svg" /> </div>
<div>9<img src="stock.svg" /> </div>
<div>10<img src="stock.svg" /> </div>
<div>11<img src="stock.svg" /> </div>
<div>12<img src="stock.svg" /> </div>
</div>
<div id="album_photos"></div>
</div>
</body>
</html>

153
web/choose.js Normal file
View File

@ -0,0 +1,153 @@
"use strict";
// elements
/** @type {HTMLButtonElement} */
let loadAlbumsBtn
/** @type {HTMLButtonElement} */
let loadPhotosBtn
/** @type {HTMLButtonElement} */
let makeCollageBtn
/** @type {HTMLSelectElement} */
let albumSelect
/** @type {HTMLParagraphElement} */
let noticeP
/** @type {HTMLDialogElement} */
let noticeDialog
/** @type {HTMLDivElement} */
let albumPhotosDiv
/** @type {HTMLDivElement} */
let selectedPhotosDiv
/** @type {HTMLImageElement} */
let selectedPhotoImg
function main() {
albumSelect = document.getElementById("album_selector")
loadAlbumsBtn = document.getElementById("load_albums_button")
loadPhotosBtn = document.getElementById("load_photos_button")
makeCollageBtn = document.getElementById("make_collage_button")
noticeDialog = document.getElementById("notice_dialog")
albumPhotosDiv = document.getElementById("album_photos")
selectedPhotosDiv = document.getElementById("selected_photos")
noticeP = document.getElementById("notice_p")
loadAlbumsBtn.onclick = () => loadAlbums()
loadPhotosBtn.onclick = () => loadPhotos()
makeCollageBtn.onclick = () => gotoCollage()
/**
* @type HTMLImageElement[]
*/
const selectedPhotos = selectedPhotosDiv.getElementsByTagName("img")
for (const img of selectedPhotos) {
img.onclick = () => {
selectedPhotoImg?.classList.remove("current")
selectedPhotoImg = img
selectedPhotoImg.classList.add("current")
}
}
selectedPhotos[0].click()
}
function loadAlbums() {
(async () => {
try {
closeNotice()
const resp = await fetch("get-albums")
const albums = await resp.json()
if(albums.length == 0) {
showNotice("No Albums found")
return
}
albumSelect.replaceChildren() // This empties existing options
for(const album of albums) {
albumSelect.add(new Option(album.Title, album.UID))
}
} catch(e) {
console.log(e)
}
})();
}
function closeNotice() {
noticeDialog.close()
}
/**
* @param {string} notice
*/
function showNotice(notice) {
noticeP.textContent = notice
noticeDialog.show()
}
function loadPhotos() {
closeNotice()
const selected = albumSelect.selectedOptions
if(selected.length != 1) {
showNotice("Select an album to load photos")
return
}
const [elem] = selected;
(async () => {
try {
const resp = await fetch(`load-photos/${elem.value}`)
/**
* @type String[]
*/
const photos = await resp.json()
if(photos.length == 0) {
showNotice("No Photos found")
return
}
albumPhotosDiv.replaceChildren()
for(const url of photos) {
const img = new Image()
img.src = url
albumPhotosDiv.appendChild(img)
}
/**
* @type HTMLImageElement[]
*/
const photoImgs = albumPhotosDiv.children
for(const photo of photoImgs) {
photo.onclick = () => {
selectedPhotoImg.src = photo.src
}
}
} catch(e) {
console.log(e)
}
})();
}
function gotoCollage() {
/**
* @type HTMLImageElement[]
*/
const selectedPhotos = selectedPhotosDiv.getElementsByTagName("img")
/**
* @type String[]
*/
let photoUrls = [];
for (const img of selectedPhotos) {
if (!img.src.endsWith("stock.svg")) {
photoUrls.push(img.src)
}
}
const encodedURLS = encodeURIComponent(JSON.stringify(photoUrls))
window.location.href = `index.html?urls=${encodedURLS}`
}
main()

1
web/croppie.min.js vendored Normal file

File diff suppressed because one or more lines are too long

108
web/index.css Normal file
View File

@ -0,0 +1,108 @@
.container {
display: flex;
background-color: lightyellow;
height: calc(100vh - 20px);
}
.controls {
background-color: lightgrey;
display: flex;
align-items: center;
justify-content: space-around;
flex-direction: column;
flex: 25%;
}
.imagebox {
padding: 2rem;
flex: 75%;
}
.image-surface {
overflow: hidden;
margin: auto;
border: 1px solid;
height: 100%;
width: auto;
--collage-ap: 110 / 85;
aspect-ratio: var(--collage-ap);
}
.showbuton {
font-size: 2rem;
}
.templates {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.templates li {
list-style-type: none;
}
#page_size_selector {
width: 100%;
}
.template {
width: 100px;
height: 100px;
}
.template-selected {
background-color: green;
}
.template div {
border: 1px solid;
}
.single div {
width: 100%;
height: 100%;
}
.one-two {
display: grid;
grid-template-areas:
"one two"
"one three";
}
.one-two .img1{
grid-area: one;
}
.half-leftright {
display:flex;
}
.half-leftright .img {
flex: 50%;
}
.half-topbottom .img {
width: 100%;
height: 50%;
}
.two-one-two-leftright {
display: grid;
grid-template-areas:
"one two two three"
"four two two five";
}
.two-one-two-leftright .img2 {
grid-area: two;
}
.two-one-two-topbottom {
display: grid;
grid-template-areas:
"one two"
"three three"
"three three"
"four five"
}
.two-one-two-topbottom .img3 {
grid-area: three;
}

83
web/index.html Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<!-- DEVONLY --> <script src="http://localhost:35729/livereload.js"></script>
<link rel="stylesheet" href="croppie.css" />
<script src="croppie.min.js" defer></script>
<script src="index.js" defer></script>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<div class="container">
<div class="controls">
<label>
<span>Paper size</span>
<select id="page_size_selector" size=8>
<option value="letter-portrait" > Letter (Portrait) </option>
<option selected value="letter-landscape" > Letter (Landscape) </option>
<option value="fiveseven-portrait" > 5 × 7 (Portrait) </option>
<option value="fiveseven-landscape" > 7 × 5 (Landscape) </option>
<option value="foursix-portrait" > 4 × 6 (Portrait) </option>
<option value="foursix-landscape" > 4 × 6 (Landscape) </option>
</select>
</label>
<ul class="templates">
<li>
<div id="default_template" class="template single" data-collage-template="single">
<div class="img img1"></div>
</div>
</li>
<li>
<div class="template one-two" data-collage-template="one-two">
<div class="img img1"></div>
<div class="img img2"></div>
<div class="img img3"></div>
</div>
</li>
<li>
<div class="template half-leftright" data-collage-template="half-leftright">
<div class="img img1"></div>
<div class="img img2"></div>
</div>
</li>
<li>
<div class="template half-topbottom" data-collage-template="half-topbottom">
<div class="img img1"></div>
<div class="img img2"></div>
</div>
</li>
<li>
<div class="template two-one-two-leftright" data-collage-template="two-one-two-leftright">
<div class="img img1"></div>
<div class="img img2"></div>
<div class="img img3"></div>
<div class="img img4"></div>
<div class="img img5"></div>
</div>
</li>
<li>
<div class="template two-one-two-topbottom" data-collage-template="two-one-two-topbottom">
<div class="img img1"></div>
<div class="img img2"></div>
<div class="img img3"></div>
<div class="img img4"></div>
<div class="img img5"></div>
</div>
</li>
</ul>
<button id="snapper" class="showbuton">Snap Collage</button>
<p><a href="" target="_blank" id="collage-url"></a></p>
</div>
<div class="imagebox">
<div id="collage" class="image-surface"></div>
</div>
</div>
</body>
</html>

221
web/index.js Normal file
View File

@ -0,0 +1,221 @@
"use strict";
const pageSizes = {
"letter-portrait" : {
"ap": "85 / 110",
"width": 3264,
"height": 4224,
},
"letter-landscape" : {
"ap": "110 / 85",
"width": 4224,
"height": 3264,
},
"fiveseven-portrait" : {
"ap": "5 / 7",
"width": 2250,
"height": 3150,
},
"fiveseven-landscape" : {
"ap": "7 / 5",
"width": 3150,
"height": 2250,
},
"foursix-portrait" : {
"ap": "4 / 6",
"width": 1800,
"height": 2700,
},
"foursix-landscape" : {
"ap": "6 / 4",
"width": 2700,
"height": 1800,
},
}
// elements
/** @type {HTMLButtonElement} */
let snapButton
/** @type {HTMLDivElement} */
let collageDiv
/** @type {HTMLSelectElement} */
let pageSizeSelect
/** @type {HTMLAnchorElement} */
let collageUrlA
// collage state
let crops = [];
let pageSize = "letter-landscape";
let imageUrls = [
, // images start with index 1
"images/img1.jpg",
"images/img2.jpg",
"images/img3.jpg",
"images/img4.jpg",
"images/img5.jpg",
"images/img6.jpg",
"images/img7.jpg",
]
function main() {
snapButton = document.getElementById("snapper")
collageDiv = document.getElementById("collage")
pageSizeSelect = document.getElementById("page_size_selector")
collageUrlA = document.getElementById("collage-url")
snapButton.onclick = () => snap()
pageSizeSelect.onchange = () => pageSizeChange()
for(const tmpl of document.getElementsByClassName("template")) {
tmpl.onclick = () => applyTemplate(tmpl)
}
const queryUrls = loadImageUrlsFromQuery()
imageUrls = [,].concat(queryUrls)
applyTemplate(document.getElementById("default_template"))
}
/**
* @param {HTMLDivElement} elem
* @param {string} imgUrl
*/
function makeCroppieElem(elem, imgUrl) {
const cpie = new Croppie(elem, {
viewport: {
width: elem.clientWidth,
height: elem.clientHeight,
type: 'square'
},
showZoomer: false,
});
cpie.bind({
url: imgUrl
});
return cpie
}
function initCollage() {
for(const cpie of crops) {
cpie.destroy()
}
crops = []
for(const elem of collageDiv.getElementsByClassName("img")) {
const cpie = makeCroppieElem(elem, elem.dataset.collageImageUrl)
const lastLen = crops.push(cpie)
elem.dataset.collageCropieIndex = lastLen - 1
}
}
async function makeCollage(req) {
const resp = await fetch("make-collage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
})
return await resp.text();
}
function snap() {
const col = collageDiv.offsetLeft;
const cot = collageDiv.offsetTop;
const req = {
background_image: "",
aspect: {
width: pageSizes[pageSize]["width"],
height: pageSizes[pageSize]["height"],
},
dimension: {
width: collageDiv.clientWidth,
height: collageDiv.clientHeight,
},
photos: [],
};
for(const elem of collageDiv.getElementsByClassName("img")) {
const cpie = crops[elem.dataset.collageCropieIndex]
const fsx = elem.offsetLeft - col
const fsy = elem.offsetTop - cot
const [sx, sy, ex, ey] = cpie.get().points;
const photo = {
image: elem.dataset.collageImageUrl.slice("images/".length),
crop: {
start: {
x: parseInt(sx),
y: parseInt(sy),
},
end: {
x: parseInt(ex),
y: parseInt(ey),
},
},
frame: {
start: {
x: fsx,
y: fsy,
},
end: {
x: fsx + elem.clientWidth,
y: fsy + elem.clientHeight,
},
},
};
req.photos.push(photo)
}
(async () => {
const collagFile = await makeCollage(req)
collageUrlA.href = `collages/${collagFile}`;
collageUrlA.text = `${collagFile} generated`;
})();
}
function pageSizeChange() {
collageDiv.style.setProperty('--collage-ap', pageSizes[pageSizeSelect.value]["ap"])
pageSize = pageSizeSelect.value
initCollage()
}
/**
* @param {HTMLDivElement} templateDiv
*/
function applyTemplate(templateDiv) {
document.getElementsByClassName("template-selected").item(0)?.classList.remove("template-selected")
/** @type {HTMLDivElement} */
const templateClone = templateDiv.cloneNode(true)
templateDiv.classList.add("template-selected")
for (const index in imageUrls) {
const url = imageUrls[index]
const imgClass = `img${index}`
const [imgDiv] = templateClone.getElementsByClassName(imgClass)
if(imgDiv === undefined) {
break;
}
imgDiv.dataset.collageImageUrl = url;
}
collageDiv.replaceChildren(...templateClone.children)
collageDiv.classList.remove(collageDiv.dataset.collageTemplate)
collageDiv.classList.add(templateDiv.dataset.collageTemplate)
collageDiv.dataset.collageTemplate = templateDiv.dataset.collageTemplate
initCollage()
}
function loadImageUrlsFromQuery() {
const params = new URLSearchParams(window.location.search)
const urlsstr = params.get('urls')
return JSON.parse(urlsstr)
}
main()

4
web/stock.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="25" r="20" stroke="green" stroke-width="4" fill="lightgrey" />
<polygon points="50,50 80,95 20,95" stroke="green" stroke-width="4" fill="lightgrey" />
</svg>

After

Width:  |  Height:  |  Size: 252 B