art.gno
6.62 Kb · 226 lines
1package nsskin
2
3import (
4 "chain/runtime"
5 "encoding/base64"
6 "strconv"
7 "strings"
8
9 "gno.land/p/nt/grc721/v0"
10)
11
12/*
13WHAT A WALLET SHOWS FOR A SKIN.
14
15Adena puts TokenURI(tid) straight into an <img src>, so it must return
16the image itself, as a data URI, and it reads TokenMetadata as a camelCase
17JSON string. The previous TokenURI returned a gnoweb path, which no wallet
18can draw.
19
20The picture is drawn HERE, from the definition's own sprite, rather than
21by nsview: nsview imports this realm to put skins on name cards, so this
22realm cannot import it back. It is the bare artwork on a dark matte —
23nsview's matted shop card stays the gnoweb page, linked as externalUrl.
24This realm is replaceable, so a richer wallet image is a redeploy away.
25
26A WITHDRAWN SKIN DRAWS NOTHING. Withdrawal is the takedown control
27(SKINS.md §6): a design that turns out to be somebody else's character or
28something vile must stop appearing in wallets with our name beside it the
29moment it is withdrawn. So its image is a neutral placeholder, its
30metadata omits its name and link, and nothing else about the token
31changes — the holder keeps it.
32*/
33
34// TokenURI is the skin's image, as a data URI, for a token that exists.
35func TokenURI(tid grc721.TokenID) (string, error) {
36 if _, err := token.OwnerOf(tid); err != nil {
37 return "", err
38 }
39 s, ok := skinOfTID(string(tid))
40 if !ok {
41 return placeholderImage("UNKNOWN SKIN"), nil
42 }
43 if s.withdrawn {
44 return placeholderImage("WITHDRAWN"), nil
45 }
46 if svg, ok := spriteSVG(s.art); ok {
47 return dataURI(svg), nil
48 }
49 return placeholderImage("ART WILL NOT RENDER"), nil
50}
51
52// TokenMetadata is the skin's metadata as a camelCase JSON string: name,
53// description, externalUrl and attributes of {traitType, value}. No image
54// in it; that is TokenURI.
55func TokenMetadata(tid grc721.TokenID) (string, error) {
56 if _, err := token.OwnerOf(tid); err != nil {
57 return "", err
58 }
59 t := string(tid)
60 id, serial := t, ""
61 if h := strings.LastIndex(t, "#"); h > 0 {
62 id, serial = t[:h], t[h+1:]
63 }
64 s, ok := skinOfTID(t)
65 if !ok || s.withdrawn {
66 return `{"name":"Withdrawn skin #` + escapeJSON(serial) + `",` +
67 `"description":"This skin has been withdrawn and no longer draws on any name.",` +
68 `"attributes":[{"traitType":"Skin","value":"` + escapeJSON(id) + `"},` +
69 `{"traitType":"Status","value":"withdrawn"}]}`, nil
70 }
71 goesOn := "any name"
72 if s.lock != "" {
73 goesOn = "*" + s.lock + " names"
74 }
75 worn := "no"
76 if bindingIsLive(t) {
77 b := bound.Get(t).(*binding)
78 worn = b.label + "*" + b.domain
79 }
80 out := `{"name":"` + escapeJSON(s.name) + ` #` + escapeJSON(serial) + `",` +
81 `"description":"Card art for a Meme Name Service name. Wear it and it travels with the name.",`
82 if u := externalURL(t); u != "" {
83 out += `"externalUrl":"` + escapeJSON(u) + `",`
84 }
85 out += `"attributes":[` +
86 `{"traitType":"Skin","value":"` + escapeJSON(id) + `"},` +
87 `{"traitType":"Edition","value":"` + escapeJSON(serial) + `"},` +
88 `{"traitType":"Goes on","value":"` + escapeJSON(goesOn) + `"},` +
89 `{"traitType":"Worn by","value":"` + escapeJSON(worn) + `"}]}`
90 return out, nil
91}
92
93func skinOfTID(tid string) (*skin, bool) {
94 id, ok := skinOfToken(tid)
95 if !ok {
96 return nil, false
97 }
98 v := skins.Get(strings.ToLower(strings.TrimSpace(id)))
99 if v == nil {
100 return nil, false
101 }
102 return v.(*skin), true
103}
104
105// externalURL is the skin's own page: tokenURIBase plus the token id, on
106// the chain's gnoweb when the base is a path. "#" would start a URL
107// fragment, so it is escaped.
108func externalURL(tid string) string {
109 esc := strings.ReplaceAll(tid, "#", "%23")
110 switch {
111 case strings.HasPrefix(tokenURIBase, "https://"):
112 return tokenURIBase + esc
113 case strings.HasPrefix(tokenURIBase, "/"):
114 return "https://" + runtime.ChainDomain() + tokenURIBase + esc
115 }
116 return ""
117}
118
119/*
120spriteSVG draws the sprite format DefineSkin validates ("A#rrggbb,..|row|row")
121as one rect per horizontal run, on a dark matte. It re-checks every rule
122assertValidArt applies rather than trusting that it ran, and refuses the
123whole sprite on any fault — a picture with holes in it is worse than an
124honest placeholder. Colours are emitted only after they are checked to be
125exactly # and six hex digits, so nothing from the definition reaches the
126SVG unvalidated.
127*/
128func spriteSVG(art string) (string, bool) {
129 parts := strings.Split(art, "|")
130 n := len(parts) - 1
131 if n < 4 || n > 64 {
132 return "", false
133 }
134 pal := map[byte]string{}
135 for _, e := range strings.Split(parts[0], ",") {
136 if len(e) != 8 || e[1] != '#' || !isHex6(e[2:]) {
137 return "", false
138 }
139 pal[e[0]] = e[1:]
140 }
141 size := strconv.Itoa(n)
142 var b strings.Builder
143 b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ` + size + ` ` + size +
144 `" shape-rendering="crispEdges"><rect width="` + size + `" height="` + size + `" fill="#0b0d10"/>`)
145 for y := 0; y < n; y++ {
146 row := parts[y+1]
147 if len(row) != n {
148 return "", false
149 }
150 for x := 0; x < n; {
151 c := row[x]
152 if c == '.' {
153 x++
154 continue
155 }
156 col, ok := pal[c]
157 if !ok {
158 return "", false
159 }
160 x0 := x
161 for x < n && row[x] == c {
162 x++
163 }
164 b.WriteString(`<rect x="` + strconv.Itoa(x0) + `" y="` + strconv.Itoa(y) +
165 `" width="` + strconv.Itoa(x-x0) + `" height="1" fill="` + col + `"/>`)
166 }
167 }
168 b.WriteString(`</svg>`)
169 return b.String(), true
170}
171
172func isHex6(s string) bool {
173 if len(s) != 6 {
174 return false
175 }
176 for i := 0; i < 6; i++ {
177 c := s[i]
178 if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
179 continue
180 }
181 return false
182 }
183 return true
184}
185
186func placeholderImage(msg string) string {
187 return dataURI(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">` +
188 `<rect width="400" height="400" fill="#0b0d10"/>` +
189 `<rect x="40" y="40" width="320" height="320" fill="none" stroke="#3f3f46" stroke-width="2" stroke-dasharray="7 6"/>` +
190 `<text x="200" y="206" text-anchor="middle" font-family="monospace" font-size="16" fill="#a1a1aa">` +
191 escapeXML(msg) + `</text></svg>`)
192}
193
194func dataURI(svg string) string {
195 return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg))
196}
197
198func escapeXML(s string) string {
199 s = strings.ReplaceAll(s, "&", "&")
200 s = strings.ReplaceAll(s, "<", "<")
201 s = strings.ReplaceAll(s, ">", ">")
202 s = strings.ReplaceAll(s, `"`, """)
203 s = strings.ReplaceAll(s, "'", "'")
204 return s
205}
206
207func escapeJSON(s string) string {
208 var b strings.Builder
209 for i := 0; i < len(s); i++ {
210 c := s[i]
211 switch {
212 case c == '"':
213 b.WriteString(`\"`)
214 case c == '\\':
215 b.WriteString(`\\`)
216 case c < 0x20:
217 const hex = "0123456789abcdef"
218 b.WriteString(`\u00`)
219 b.WriteByte(hex[c>>4])
220 b.WriteByte(hex[c&0xf])
221 default:
222 b.WriteByte(c)
223 }
224 }
225 return b.String()
226}