From 90f07abe5f6730ca545fcb491251789415dbdfc0 Mon Sep 17 00:00:00 2001 From: pawndev Date: Tue, 13 Oct 2026 17:47:00 +0200 Subject: [PATCH] Frame your own label image before saving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Use my own image…" used to write the label straight away, centred on the picture. It now opens the picture in the label editor, so you can move and resize the box before saving. - The box always keeps the label's shape (corners and mouse wheel), so labels are never stretched. This applies to RomM photos too. - On your own picture the box may go past the edges, up to fitting the whole picture; the area outside comes out white. - The chosen picture is held in memory until it's saved or cancelled. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- app.go | 60 ++++++++++++----- frontend/app.js | 115 +++++++++++++++++++++++--------- frontend/style.css | 7 +- internal/core/core.go | 129 +++++++++++++++++++++++++++++++++--- internal/core/core_test.go | 59 +++++++++++++---- internal/label/label.go | 43 +++++++++++- internal/label/room_test.go | 43 ++++++++++++ preview.go | 13 ++-- 9 files changed, 393 insertions(+), 81 deletions(-) create mode 100644 internal/label/room_test.go diff --git a/README.md b/README.md index 1f18767..5108cde 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,10 @@ shell colour. One `SlotDrop.exe`, nothing to install. Click any game to see its cartridge. For games on the card you can pick a shell colour (**GBA Grey** is what new games get; Slot draws a game with no colour in plain black), choose a finish, redo the label by dragging a box on RomM's -cartridge photo, or *Use my own image…*. If games on the card have no label, +cartridge photo, or *Use my own image…* and frame it the same way. The box +keeps the label's shape: drag it to move it, and drag a corner or use the +mouse wheel to resize it. On your own image it can go past the edges, which +come out white. If games on the card have no label, *Make labels* does them all. When you're done, the eject button next to the card in the top bar makes it safe to pull out. diff --git a/app.go b/app.go index eb81495..5049768 100644 --- a/app.go +++ b/app.go @@ -357,31 +357,41 @@ func (a *App) SaveLabel(platform, stem string, romID int, box label.Box, shell * return friendly(a.core.SaveLabel(platform, stem, romID, &box, shell)) } -// ChooseLabel asks for a picture and makes it the game's label. It returns -// false if you cancelled. -func (a *App) ChooseLabel(platform, stem string) (bool, error) { +// ChooseLabel asks for a picture for a platform's label and gets it ready to +// frame. It returns nil if you cancelled; SaveOwnLabel writes it. +func (a *App) ChooseLabel(platform string) (*core.LabelPlan, error) { path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Choose a label picture", Filters: []runtime.FileFilter{{DisplayName: "Pictures (*.png, *.jpg, *.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp;*.gif"}}, }) if err != nil || path == "" { - return false, friendly(err) + return nil, friendly(err) } f, err := os.Open(path) if err != nil { - return false, friendly(err) + return nil, friendly(err) } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, romm.MaxImageBytes+1)) if err != nil { - return false, friendly(err) + return nil, friendly(err) } if len(data) > romm.MaxImageBytes { - return false, errors.New("That picture is too big.") + return nil, errors.New("That picture is too big.") } - return true, friendly(a.core.LabelFromImage(platform, stem, data)) + plan, err := a.core.PlanOwnLabel(platform, data) + return plan, friendly(err) +} + +// SaveOwnLabel writes the picture you chose, cut at box, as a card game's +// label. A null shell leaves the game's colour as it is. +func (a *App) SaveOwnLabel(platform, stem, token string, box label.Box, shell *sdcard.Shell) error { + return friendly(a.core.SaveOwnLabel(platform, stem, token, box, shell)) } +// DropOwnLabel forgets the picture you chose, when you cancel. +func (a *App) DropOwnLabel(token string) { a.core.DropOwnLabel(token) } + // SetShell changes just a game's shell colour. func (a *App) SetShell(stem string, shell sdcard.Shell) error { return friendly(a.core.SetShell(stem, shell)) @@ -408,6 +418,8 @@ func (a *App) SetMatch(platform, stem string, romID int) { a.core.SetMatch(platf // /img/cart/ cartridge photo // /img/label// a label already on the card // /img/crop/?box=... the label cut from the cartridge photo +// /img/own/ the picture you chose for a label +// /img/owncrop/?box= the label cut from it // // platformIcon is RomM's own icon for a console, fetched once per server. func (a *App) platformIcon(key string) ([]byte, error) { @@ -435,6 +447,19 @@ func (a *App) platformIcon(key string) ([]byte, error) { return data, nil } +// boxParam reads ?box=l,t,r,b, or nil if there isn't one. +func boxParam(r *http.Request) *label.Box { + f := strings.Split(r.URL.Query().Get("box"), ",") + if len(f) != 4 { + return nil + } + var b label.Box + for i := range f { + b[i], _ = strconv.Atoi(f[i]) + } + return &b +} + func (a *App) imageHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { parts := strings.SplitN(strings.TrimPrefix(r.URL.EscapedPath(), "/img/"), "/", 2) @@ -470,15 +495,18 @@ func (a *App) imageHandler() http.Handler { http.NotFound(w, r) return } - var box *label.Box - if f := strings.Split(r.URL.Query().Get("box"), ","); len(f) == 4 { - var b label.Box - for i := range f { - b[i], _ = strconv.Atoi(f[i]) - } - box = &b + data, err = a.core.CropPNG(id, boxParam(r)) + case "own": + data, err = a.core.OwnImage(parts[1]) + w.Header().Set("Cache-Control", "no-store") + case "owncrop": + box := boxParam(r) + if box == nil { + http.NotFound(w, r) + return } - data, err = a.core.CropPNG(id, box) + data, err = a.core.OwnCropPNG(parts[1], *box) + w.Header().Set("Cache-Control", "no-store") case "platform": // /img/platform/GBA: the console icon from your RomM, if it has one. data, err = a.platformIcon(parts[1]) diff --git a/frontend/app.js b/frontend/app.js index 1fdf143..2d64cd8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -862,13 +862,13 @@ document.addEventListener("keydown", (e) => { function openDrawer(g) { state.drawer = g; - state.editor = null; + closeEditor(); state.pendingShell = null; refreshMain(); } function closeDrawer() { state.drawer = null; - state.editor = null; + closeEditor(); refreshMain(); } @@ -885,9 +885,10 @@ function drawerEl() { // The big picture: the finished cartridge, drawn like Slot shows it. const hero = h("div", { class: "hero" }); const showHero = () => { - const box = state.editor ? state.editor.box : null; + const ed = state.editor; let src = null; - if (box) src = `/img/crop/${g.romId}?box=${box.join(",")}`; + if (ed && ed.token) src = `/img/owncrop/${ed.token}?box=${ed.box.join(",")}`; + else if (ed) src = `/img/crop/${g.romId}?box=${ed.box.join(",")}`; else if (g.onCard && g.hasLabel) src = cardLabelURL(g); else if (g.romId && g.hasCart) src = `/img/crop/${g.romId}`; if (!src && !g.onCard && g.hasCover) { @@ -990,7 +991,9 @@ function labelGroup(g, shell, showHero) { // Label position on the photo. if (ed) { group.append(boxEditor(g, ed, showHero)); - group.append(h("p", { class: "faint", style: { margin: "0 0 12px", fontSize: "12.5px" } }, "Drag the box to move it, or a corner to resize. The preview above updates when you let go.")); + group.append(h("p", { class: "faint", style: { margin: "0 0 12px", fontSize: "12.5px" } }, + "Drag the box to move it, or a corner or the mouse wheel to resize. The preview above updates when you let go.", + ed.token ? " Past the picture's edge comes out white." : "")); } const fromPhoto = g.romId && g.hasCart; // RomM has a cartridge photo to cut the label from @@ -1005,7 +1008,7 @@ function labelGroup(g, shell, showHero) { if (!g.hasLabel && !ed && fromPhoto) { actions.append(h("button", { class: "btn primary", onclick: (e) => saveLabel(g, null, state.pendingShell, e.currentTarget) }, icon("wand"), "Make label")); } else if (ed) { - actions.append(h("button", { class: "btn ghost", onclick: () => { state.editor = null; refreshMain(); } }, "Cancel")); + actions.append(h("button", { class: "btn ghost", onclick: () => { closeEditor(); refreshMain(); } }, "Cancel")); actions.append(h("button", { class: "btn primary", onclick: (e) => saveLabel(g, ed.box, state.pendingShell, e.currentTarget) }, "Save label")); } else if (dirty) { actions.append(h("button", { class: "btn primary", onclick: (e) => saveShell(g, shell, e.currentTarget) }, "Save colour")); @@ -1026,17 +1029,52 @@ async function startEditor(g, btn) { refreshMain(); } +// closeEditor stops framing a label, and lets go of a picture you chose. +function closeEditor() { + if (state.editor && state.editor.token) api().DropOwnLabel(state.editor.token).catch(() => {}); + state.editor = null; +} + +// boxEditor frames the label on a picture: drag the box to move it, a corner +// or the wheel to resize it, always in the label's shape so it isn't +// stretched. On a picture you chose the box may go past its edges (by +// ed.marginX and ed.marginY) to leave white around it. function boxEditor(g, ed, showHero) { + const mx = ed.marginX || 0, my = ed.marginY || 0; + const L = -mx, T = -my, R = ed.width + mx, B = ed.height + my; // where the box may go + const ew = R - L, eh = B - T; + const minW = Math.max(16, 16 * ed.aspect); + const maxW = Math.min(ew, eh * ed.aspect); const boxEl = h("div", { class: "box" }, ["nw", "ne", "sw", "se"].map((k) => h("div", { class: "h", "data-h": k }))); - const wrap = h("div", { class: "editor" }, h("img", { src: `/img/cart/${g.romId}`, alt: "" }), boxEl); + const pic = h("img", { + src: ed.token ? `/img/own/${ed.token}` : `/img/cart/${g.romId}`, alt: "", + style: { left: (mx / ew) * 100 + "%", top: (my / eh) * 100 + "%", width: (ed.width / ew) * 100 + "%", height: (ed.height / eh) * 100 + "%" }, + }); + const wrap = h("div", { + class: "editor" + (ed.token ? " loose" : ""), + style: { aspectRatio: `${ew} / ${eh}`, width: `min(100%, ${((55 * ew) / eh).toFixed(2)}vh)` }, + }, pic, boxEl); const place = () => { const [l, t, r, b] = ed.box; Object.assign(boxEl.style, { - left: (l / ed.width) * 100 + "%", top: (t / ed.height) * 100 + "%", - width: ((r - l) / ed.width) * 100 + "%", height: ((b - t) / ed.height) * 100 + "%", + left: ((l - L) / ew) * 100 + "%", top: ((t - T) / eh) * 100 + "%", + width: ((r - l) / ew) * 100 + "%", height: ((b - t) / eh) * 100 + "%", }); }; + // fit puts a box of width w at (l, t), in the label's shape and inside the area. + const fit = (l, t, w) => { + w = Math.min(Math.max(w, minW), maxW); + const hgt = w / ed.aspect; + l = Math.min(Math.max(L, l), R - w); + t = Math.min(Math.max(T, t), B - hgt); + ed.box = [l, t, l + w, t + hgt].map(Math.round); + place(); + }; place(); + const done = () => { + ed.moved = true; + showHero(); + }; let drag = null; boxEl.addEventListener("pointerdown", (e) => { e.preventDefault(); @@ -1046,30 +1084,45 @@ function boxEditor(g, ed, showHero) { boxEl.addEventListener("pointermove", (e) => { if (!drag) return; const rect = wrap.getBoundingClientRect(); - const dx = ((e.clientX - drag.x) / rect.width) * ed.width; - const dy = ((e.clientY - drag.y) / rect.height) * ed.height; - let [l, t, r, b] = drag.box; - const w = r - l, hgt = b - t; + const dx = ((e.clientX - drag.x) / rect.width) * ew; + const dy = ((e.clientY - drag.y) / rect.height) * eh; + const [l, t, r, b] = drag.box; if (drag.handle === "move") { - l = Math.min(Math.max(0, l + dx), ed.width - w); t = Math.min(Math.max(0, t + dy), ed.height - hgt); - r = l + w; b = t + hgt; - } else { - if (drag.handle.includes("w")) l = Math.min(Math.max(0, l + dx), r - 10); - if (drag.handle.includes("e")) r = Math.max(Math.min(ed.width, r + dx), l + 10); - if (drag.handle.includes("n")) t = Math.min(Math.max(0, t + dy), b - 10); - if (drag.handle.includes("s")) b = Math.max(Math.min(ed.height, b + dy), t + 10); + fit(l + dx, t + dy, r - l); + return; } - ed.box = [l, t, r, b].map(Math.round); - place(); + // A corner: the opposite corner stays put, and the box grows to reach + // the pointer in whichever direction it went further. + const east = drag.handle.includes("e"), south = drag.handle.includes("s"); + const ax = east ? l : r, ay = south ? t : b; + const wide = east ? r + dx - ax : ax - (l + dx); + const tall = south ? b + dy - ay : ay - (t + dy); + let w = Math.max(wide, tall * ed.aspect); + w = Math.min(Math.max(w, minW), east ? R - ax : ax - L, (south ? B - ay : ay - T) * ed.aspect); + fit(east ? ax : ax - w, south ? ay : ay - w / ed.aspect, w); }); const end = () => { if (!drag) return; drag = null; - ed.moved = true; - showHero(); + done(); }; boxEl.addEventListener("pointerup", end); boxEl.addEventListener("pointercancel", end); + // The wheel zooms around the pointer: down shows more of the picture. + let settle = 0; + wrap.addEventListener("wheel", (e) => { + e.preventDefault(); + const rect = wrap.getBoundingClientRect(); + const px = L + ((e.clientX - rect.left) / rect.width) * ew; + const py = T + ((e.clientY - rect.top) / rect.height) * eh; + const [l, t, r, b] = ed.box; + const w = Math.min(Math.max((r - l) * Math.exp(e.deltaY * 0.0015), minW), maxW); + const fx = Math.min(Math.max((px - l) / (r - l), 0), 1); + const fy = Math.min(Math.max((py - t) / (b - t), 0), 1); + fit(px - fx * w, py - fy * (w / ed.aspect), w); + clearTimeout(settle); + settle = setTimeout(done, 250); + }, { passive: false }); return wrap; } @@ -1077,7 +1130,8 @@ async function saveLabel(g, box, shell, btn) { btn.disabled = true; btn.replaceChildren(h("span", { class: "spinner" }), "Saving…"); try { - if (box) await api().SaveLabel(g.platform, g.stem, g.romId, box, shell); + if (box && state.editor && state.editor.token) await api().SaveOwnLabel(g.platform, g.stem, state.editor.token, box, shell); + else if (box) await api().SaveLabel(g.platform, g.stem, g.romId, box, shell); else { await api().SaveLabel(g.platform, g.stem, g.romId, (await api().PlanLabel(g.romId)).box, shell); } @@ -1092,14 +1146,15 @@ async function saveLabel(g, box, shell, btn) { refreshMain(); } -// Pick a picture on the PC (a label scan, or a whole cartridge photo) for a game's label. +// Pick a picture on the PC (a label scan, or a whole cartridge photo) for a +// game's label, then frame it in the editor before saving. async function chooseLabel(g, btn) { btn.disabled = true; try { - if (await api().ChooseLabel(g.platform, g.stem)) { - state.labelVersion = Date.now(); - toast(`Saved your label for ${g.title}.`); - await reload(); + const plan = await api().ChooseLabel(g.platform); + if (plan) { + closeEditor(); + state.editor = { ...plan, box: plan.box.slice(), moved: false }; refreshMain(); return; } diff --git a/frontend/style.css b/frontend/style.css index 4858df7..782fb86 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -329,8 +329,11 @@ body.selecting #toasts { bottom: 88px; } .facts { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 13px; margin-bottom: 16px; } .facts dt { color: var(--text-3); } .facts dd { margin: 0; user-select: text; word-break: break-word; } -.editor { position: relative; border-radius: 12px; overflow: hidden; background: #101018; margin-bottom: 12px; touch-action: none; } -.editor img { width: 100%; display: block; pointer-events: none; } +.editor { position: relative; border-radius: 12px; overflow: hidden; background: #101018; margin: 0 auto 12px; touch-action: none; } +.editor img { position: absolute; display: block; pointer-events: none; } +/* A picture you chose: room around it, white like the label comes out. */ +.editor.loose { background: #fff; } +.editor.loose img { outline: 1px dashed rgba(0,0,0,.3); } .editor .box { position: absolute; border: 2px solid var(--gbc); box-shadow: 0 0 0 9999px rgba(0,0,0,.45); cursor: move; border-radius: 2px; } diff --git a/internal/core/core.go b/internal/core/core.go index d0c9d4a..3836e64 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -4,6 +4,7 @@ package core import ( + "crypto/rand" "crypto/sha256" "errors" "fmt" @@ -35,6 +36,17 @@ type Core struct { imgMu sync.Mutex carts map[int]*cartImage cartKeys []int + own *ownPicture +} + +// ownPicture is a picture you chose for a label, kept while you frame it. +type ownPicture struct { + token string + p *platforms.Platform + img *image.NRGBA + shown []byte // smaller PNG for the editor + how string // how its first box was found + mx, my int // how far the box may stick out past it } type cartImage struct { @@ -414,6 +426,11 @@ type LabelPlan struct { Suggested sdcard.Shell `json:"suggested"` // CartColour is the plastic's colour in the photo, if it's coloured. CartColour string `json:"cartColour,omitempty"` + // For a picture you chose: what the page calls it, and how far past + // its edges the box may go. + Token string `json:"token,omitempty"` + MarginX int `json:"marginX,omitempty"` + MarginY int `json:"marginY,omitempty"` } // PlanLabel finds the label on a game's cartridge photo. @@ -510,10 +527,85 @@ func giveDefaultShell(root, stem string, img *image.NRGBA, box label.Box, how st return sdcard.SetShells(root, map[string]sdcard.Shell{stem: shell}) } -// LabelFromImage makes a card game's label from a picture you chose. A whole -// cartridge photo has its label found and cut out; anything else is used as -// the label, trimmed to the label's shape. -func (c *Core) LabelFromImage(platform, stem string, data []byte) error { +// ownMaxSide is how big a picture you chose is kept: plenty for a label +// 1280 wide, and small enough to frame quickly. +const ownMaxSide = 2400 + +// PlanOwnLabel gets a picture you chose ready to frame as a platform's label. +// A whole cartridge photo starts with its label found; anything else starts +// with the biggest centred box of the label's shape. Nothing is written +// until SaveOwnLabel. +func (c *Core) PlanOwnLabel(platform string, data []byte) (*LabelPlan, error) { + p := platforms.ByKey(platform) + if p == nil { + return nil, fmt.Errorf("unknown console %q", platform) + } + img, err := label.Decode(data) + if err != nil { + return nil, err + } + img = label.Shrink(img, ownMaxSide) + aspect := float64(p.LabelW) / float64(p.LabelH) + box, how := label.FindBox(img, p) + if how != "template" && how != "detected" { + box, how = label.FitBox(img, aspect), "fitted" + } + shown, err := label.EncodePNG(label.Shrink(img, 1000)) + if err != nil { + return nil, err + } + own := &ownPicture{token: rand.Text(), p: p, img: img, shown: shown, how: how} + own.mx, own.my = label.Room(img, aspect) + plan := &LabelPlan{ + Width: img.Bounds().Dx(), Height: img.Bounds().Dy(), Box: box, How: how, Aspect: aspect, + Suggested: sdcard.Shell{Colour: label.ShellColour(label.Crop(img, box, p)), Finish: "solid"}, + Token: own.token, MarginX: own.mx, MarginY: own.my, + } + if how == "template" || how == "detected" { + plan.CartColour, _ = label.PlasticColour(img, box) + } + c.imgMu.Lock() + c.own = own + c.imgMu.Unlock() + return plan, nil +} + +// ownPicture is the picture you're framing, if token is still it. +func (c *Core) ownPicture(token string) (*ownPicture, error) { + c.imgMu.Lock() + defer c.imgMu.Unlock() + if c.own == nil || c.own.token != token { + return nil, errors.New("that picture isn't open any more: choose it again") + } + return c.own, nil +} + +// OwnImage is the picture you're framing, for the editor. +func (c *Core) OwnImage(token string) ([]byte, error) { + own, err := c.ownPicture(token) + if err != nil { + return nil, err + } + return own.shown, nil +} + +// OwnCropPNG is the label cut from the picture you're framing at box, for +// the preview. +func (c *Core) OwnCropPNG(token string, box label.Box) ([]byte, error) { + own, err := c.ownPicture(token) + if err != nil { + return nil, err + } + if box, err = label.ClampRoom(box, own.img, own.mx, own.my); err != nil { + return nil, err + } + return label.EncodePNG(label.Crop(own.img, box, own.p)) +} + +// SaveOwnLabel writes the picture you framed, cut at box, as a card game's +// label (and shell colour, if given; nil gives a game with none its usual +// one). +func (c *Core) SaveOwnLabel(platform, stem, token string, box label.Box, shell *sdcard.Shell) error { p := platforms.ByKey(platform) root := c.Root() if p == nil || root == "" { @@ -522,22 +614,37 @@ func (c *Core) LabelFromImage(platform, stem string, data []byte) error { if sdcard.SafeFileName(stem) != stem { return fmt.Errorf("%q isn't a game on the card", stem) } - img, err := label.Decode(data) + own, err := c.ownPicture(token) if err != nil { return err } - box, how := label.FindBox(img, p) - if how != "template" && how != "detected" { - box = label.FitBox(img, float64(p.LabelW)/float64(p.LabelH)) + if own.p != p { + return fmt.Errorf("that picture was framed for %s, not %s", own.p.Key, p.Key) } - out, err := label.EncodePNG(label.Crop(img, box, p)) + if box, err = label.ClampRoom(box, own.img, own.mx, own.my); err != nil { + return err + } + data, err := label.EncodePNG(label.Crop(own.img, box, p)) if err != nil { return err } - if err := sdcard.WriteFileSafely(sdcard.LabelPath(root, p, stem), out); err != nil { + if err := sdcard.WriteFileSafely(sdcard.LabelPath(root, p, stem), data); err != nil { return fmt.Errorf("couldn't write the label: %v", err) } - return giveDefaultShell(root, stem, img, box, how) + c.DropOwnLabel(token) + if shell == nil { + return giveDefaultShell(root, stem, own.img, box, own.how) + } + return sdcard.SetShells(root, map[string]sdcard.Shell{stem: *shell}) +} + +// DropOwnLabel forgets the picture you were framing. +func (c *Core) DropOwnLabel(token string) { + c.imgMu.Lock() + if c.own != nil && c.own.token == token { + c.own = nil + } + c.imgMu.Unlock() } // SetShell changes just a game's shell colour. diff --git a/internal/core/core_test.go b/internal/core/core_test.go index b5962a2..24ad23d 100644 --- a/internal/core/core_test.go +++ b/internal/core/core_test.go @@ -250,30 +250,63 @@ func TestUnpack(t *testing.T) { } } -func TestLabelFromImage(t *testing.T) { +func TestOwnLabel(t *testing.T) { root := makeCard(t) c := New() c.OpenCard(root) - sq := image.NewNRGBA(image.Rect(0, 0, 400, 400)) // a square picture: trimmed, not stretched + sq := image.NewNRGBA(image.Rect(0, 0, 400, 400)) // a black square picture: trimmed, not stretched + for i := 3; i < len(sq.Pix); i += 4 { + sq.Pix[i] = 255 + } var buf bytes.Buffer png.Encode(&buf, sq) - if err := c.LabelFromImage("GBA", "Pokemon Radical Red", buf.Bytes()); err != nil { + plan, err := c.PlanOwnLabel("GBA", buf.Bytes()) + if err != nil { t.Fatal(err) } - f, _ := os.Open(filepath.Join(root, "Labels/GBA/Pokemon Radical Red.png")) - cfg, err := png.DecodeConfig(f) - f.Close() - if err != nil || cfg.Width != 1280 || cfg.Height != 661 { - t.Fatalf("label %v %v", cfg, err) + if b := plan.Box; plan.How != "fitted" || b[0] != 0 || b[2] != 400 || b[1] <= 0 || b[3] >= 400 { + t.Fatalf("fit box %v %s", b, plan.How) } - if b := label.FitBox(sq, 1280.0/661); b[0] != 0 || b[2] != 400 || b[1] <= 0 || b[3] >= 400 { - t.Fatalf("fit box %v", b) + if plan.Token == "" || plan.MarginX <= 0 || plan.MarginY < 0 { + t.Fatalf("plan %+v", plan) } - cart, _ := fakeCart() // a whole cartridge photo: the label is cut out - if err := c.LabelFromImage("GBA", "Pokemon Radical Red", cart); err != nil { + if _, err := c.OwnImage(plan.Token); err != nil { t.Fatal(err) } - if err := c.LabelFromImage("GBA", "../evil", cart); err == nil { + if err := c.SaveOwnLabel("GBA", "../evil", plan.Token, plan.Box, nil); err == nil { t.Fatal("bad names should be refused") } + // Zoomed right out: the whole picture, with white either side. + wide := label.Box{-plan.MarginX, -plan.MarginY, 400 + plan.MarginX, 400 + plan.MarginY} + if _, err := c.OwnCropPNG(plan.Token, wide); err != nil { + t.Fatal(err) + } + if err := c.SaveOwnLabel("GBA", "Pokemon Radical Red", plan.Token, wide, nil); err != nil { + t.Fatal(err) + } + f, _ := os.Open(filepath.Join(root, "Labels/GBA/Pokemon Radical Red.png")) + lbl, err := png.Decode(f) + f.Close() + if err != nil || lbl.Bounds().Dx() != 1280 || lbl.Bounds().Dy() != 661 { + t.Fatalf("label %v %v", lbl.Bounds(), err) + } + if r, g, b, _ := lbl.At(5, 330).RGBA(); r>>8 != 255 || g>>8 != 255 || b>>8 != 255 { + t.Fatalf("past the picture should be white, got %d %d %d", r>>8, g>>8, b>>8) + } + if r, _, _, _ := lbl.At(640, 330).RGBA(); r>>8 > 10 { + t.Fatalf("the picture itself should be black, got %d", r>>8) + } + if err := c.SaveOwnLabel("GBA", "Pokemon Radical Red", plan.Token, plan.Box, nil); err == nil { + t.Fatal("a saved picture should be forgotten") + } + + cart, _ := fakeCart() // a whole cartridge photo: starts on its label + plan, err = c.PlanOwnLabel("GBA", cart) + if err != nil || (plan.How != "template" && plan.How != "detected") { + t.Fatalf("cart photo: %+v %v", plan, err) + } + c.DropOwnLabel(plan.Token) + if _, err := c.OwnImage(plan.Token); err == nil { + t.Fatal("a dropped picture should be gone") + } } diff --git a/internal/label/label.go b/internal/label/label.go index 2c84120..8e18df1 100644 --- a/internal/label/label.go +++ b/internal/label/label.go @@ -340,12 +340,51 @@ func Clamp(b Box, img image.Image) (Box, error) { return b, nil } -// Crop cuts out the label and scales it to the size Slot uses. +// Room is how far a box may stick out past a picture you chose (mx left +// and right, my above and below): enough to fit the whole picture in the +// label's shape (width / height), with a little white to spare. +func Room(img image.Image, aspect float64) (mx, my int) { + w, h := float64(img.Bounds().Dx()), float64(img.Bounds().Dy()) + bw := max(w, h*aspect) * 1.1 + bh := bw / aspect + return int(math.Ceil((bw - w) / 2)), int(math.Ceil((bh - h) / 2)) +} + +// ClampRoom is Clamp for a picture you chose: the box may stick out past it +// by up to mx and my (see Room), but must still cover some of it. +func ClampRoom(b Box, img image.Image, mx, my int) (Box, error) { + w, h := img.Bounds().Dx(), img.Bounds().Dy() + if b[0] > b[2] { + b[0], b[2] = b[2], b[0] + } + if b[1] > b[3] { + b[1], b[3] = b[3], b[1] + } + b[0], b[1] = max(-mx, b[0]), max(-my, b[1]) + b[2], b[3] = min(w+mx, b[2]), min(h+my, b[3]) + if b[2]-b[0] < 8 || b[3]-b[1] < 8 { + return b, fmt.Errorf("the label box is too small") + } + if b[2] <= 0 || b[3] <= 0 || b[0] >= w || b[1] >= h { + return b, fmt.Errorf("the label box is off the picture") + } + return b, nil +} + +// Crop cuts out the label and scales it to the size Slot uses. Any part of +// the box past the picture's edge comes out white. func Crop(img *image.NRGBA, b Box, p *platforms.Platform) *image.NRGBA { out := image.NewNRGBA(image.Rect(0, 0, p.LabelW, p.LabelH)) // Labels are opaque: fill white first so any see-through edge isn't black. draw.Draw(out, out.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - draw.CatmullRom.Scale(out, out.Bounds(), img, image.Rect(b[0], b[1], b[2], b[3]), draw.Over, nil) + r := image.Rect(b[0], b[1], b[2], b[3]) + src := img + if !r.In(img.Bounds()) { + // Copy what there is into a see-through picture the box's size. + src = image.NewNRGBA(r) + draw.Draw(src, r, img, r.Min, draw.Src) + } + draw.CatmullRom.Scale(out, out.Bounds(), src, r, draw.Over, nil) return out } diff --git a/internal/label/room_test.go b/internal/label/room_test.go new file mode 100644 index 0000000..ea170ef --- /dev/null +++ b/internal/label/room_test.go @@ -0,0 +1,43 @@ +package label + +import ( + "image" + "image/color" + "testing" + + "slotlabelmaker/internal/platforms" +) + +func TestRoomFitsTallPictures(t *testing.T) { + tall := image.NewNRGBA(image.Rect(0, 0, 300, 900)) + aspect := float64(platforms.GBA.LabelW) / float64(platforms.GBA.LabelH) + mx, my := Room(tall, aspect) + // Zoomed right out, the box is in the label's shape and holds the whole picture. + w, h := 300+2*mx, 900+2*my + if h < 900 || w < int(900*aspect) { + t.Fatalf("room %d %d leaves a %dx%d box", mx, my, w, h) + } + if _, err := ClampRoom(Box{-mx, -my, 300 + mx, 900 + my}, tall, mx, my); err != nil { + t.Fatal(err) + } + if _, err := ClampRoom(Box{-mx, -my, -mx + 20, -my + 20}, tall, mx, my); err == nil { + t.Fatal("a box off the picture should be refused") + } + if b, _ := ClampRoom(Box{-5000, 0, 100, 100}, tall, mx, my); b[0] != -mx { + t.Fatalf("box not kept in the room: %v", b) + } +} + +func TestCropPastTheEdge(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 100, 100)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i], img.Pix[i+3] = 200, 255 // red + } + out := Crop(img, Box{-100, 0, 100, 100}, platforms.GBA) // left half past the edge + if c := out.NRGBAAt(10, 300); c != (color.NRGBA{255, 255, 255, 255}) { + t.Fatalf("past the edge should be white, got %v", c) + } + if c := out.NRGBAAt(1000, 300); c.R < 190 || c.G > 10 { + t.Fatalf("the picture should be red, got %v", c) + } +} diff --git a/preview.go b/preview.go index 801029f..11c94b5 100644 --- a/preview.go +++ b/preview.go @@ -201,13 +201,14 @@ func main() { case "MakeMissingLabels": made, probs := app.core.MissingLabels(func(int, int) {}) out = MissingResult{made, probs} - case "ChooseLabel": // no file dialog in a browser: use a label from the real card - var plat, stem string + case "ChooseLabel": // no file dialog in a browser: frame $PREVIEW_PICTURE, or a box art + var plat string json.Unmarshal(args[0], &plat) - json.Unmarshal(args[1], &stem) - data, _ := os.ReadFile(filepath.Join(realCard, "Labels/GBA/Pokemon UnBound.png")) - err = app.core.LabelFromImage(plat, stem, data) - out = err == nil + data, readErr := os.ReadFile(os.Getenv("PREVIEW_PICTURE")) + if readErr != nil { + data = cover(3) + } + out, err = app.core.PlanOwnLabel(plat, data) case "BrowseForCard": out = "" default: