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: