← All ivy-nodes
IVYXSTUDIO · IVY NODE
D

Design: a key visual as PNG

ivy.node.design-png-art · v0.1.0

ivyx✓

Paints an abstract key visual (gradient, waves, grid or blobs) in a palette and writes it as a PNG with the standard library, no Pillow. The picture behind a card or a post; deterministic for a seed.

#design#png#visual#agency

Inputs

FieldTypeDescription
paletteobjectbg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair.
stylestringgradient when omitted.
sizestringWxH up to 1600 a side; 1200x630 when omitted.
seedintegerThe same seed paints the same picture.
captionstringNot declared
name_prefixstringNot declared

Outputs

FieldTypeDescription
filesrequiredarrayThe files made, each with its bytes as base64.
countrequiredintegerNot declared
paletteobjectbg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair.
stylestringNot declared

Source

python

# Input preparation
inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]

# Compute
# Shared by every design node (built into each by build.mjs): colours, text, files.
import base64, re, math

SAFE_PALETTES = [
    {"bg": "#1E1B4B", "accent": "#F6A23B", "text": "#F8F7FF"},
    {"bg": "#F4F2FF", "accent": "#6C3BFF", "text": "#1E1B4B"},
    {"bg": "#0F3D3E", "accent": "#E8C547", "text": "#F3F7F6"},
    {"bg": "#FFF4E6", "accent": "#D9480F", "text": "#2B1D12"},
    {"bg": "#101418", "accent": "#3DDC97", "text": "#F2F4F6"},
]

def hex_ok(s):
    return isinstance(s, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", s.strip()) is not None

def palette_of(p, seed=0):
    """A palette with three valid hex colours; what the model got wrong is replaced from a safe pair."""
    base = SAFE_PALETTES[abs(int(seed)) % len(SAFE_PALETTES)]
    out = {}
    p = p if isinstance(p, dict) else {}
    for k in ("bg", "accent", "text"):
        v = p.get(k)
        out[k] = v.strip().upper() if hex_ok(v) else base[k]
    if out["bg"].upper() == out["text"].upper():
        out["text"] = base["text"] if base["text"].upper() != out["bg"].upper() else base["bg"]
    return out

def rgb(h):
    h = h.lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

def mix(a, b, t):
    return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))

def to_hex(c):
    return "#%02X%02X%02X" % c

def lum(h):
    r, g, b = [x / 255 for x in rgb(h)]
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))

def text_of(v, limit, default=""):
    s = " ".join(str(v).split()) if isinstance(v, (str, int, float)) else ""
    if not s:
        s = default
    return s[:limit].rstrip() if len(s) > limit else s

def wrap(s, max_chars):
    words = s.split()
    lines, cur = [], ""
    for w in words:
        if cur and len(cur) + 1 + len(w) > max_chars:
            lines.append(cur)
            cur = w
        else:
            cur = (cur + " " + w).strip()
    if cur:
        lines.append(cur)
    return lines or [""]

def b64(data):
    return base64.b64encode(data).decode("ascii")

def file_entry(name, mime, data, kind, width=None, height=None, caption=None, variant=None, extra=None):
    f = {"name": name, "mime": mime, "kind": kind, "bytes": len(data), "data": b64(data)}
    if width:
        f["width"] = int(width)
    if height:
        f["height"] = int(height)
    if caption:
        f["caption"] = caption
    if variant:
        f["variant"] = variant
    if extra:
        f.update(extra)
    return f

def slug(s):
    s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")
    return s[:40] or "design"

def font():
    return "Inter, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"

# design-png-art: a raster key visual (gradient, waves, grid, blobs) written as PNG with the standard library.
import zlib, struct, math, random


class DesignPngArt:
    "Design: a key visual as PNG"

    def run(self, inp):
        out = {}
        pal = palette_of(inp.get("palette"), inp.get("seed") or 0)
        style = inp.get("style") if inp.get("style") in ("gradient", "waves", "grid", "blobs") else "gradient"
        try:
            w, h = [int(x) for x in str(inp.get("size") or "1200x630").lower().split("x")]
        except Exception:
            w, h = 1200, 630
        w, h = max(64, min(w, 1600)), max(64, min(h, 1600))
        seed = int(inp.get("seed") or 7)
        rnd = random.Random(seed)
        bg, ac, tx = rgb(pal["bg"]), rgb(pal["accent"]), rgb(pal["text"])

        def clamp(v):
            return 0 if v < 0 else 255 if v > 255 else int(v)

        rows = []
        if style == "gradient":
            hx, hy, hr = rnd.uniform(0.55, 0.85), rnd.uniform(0.2, 0.5), rnd.uniform(0.35, 0.55)
            for y in range(h):
                row = bytearray()
                fy = y / h
                for x in range(w):
                    fx = x / w
                    t = (fx * 0.6 + fy * 0.4)
                    c = mix(bg, ac, t * 0.85)
                    d = math.hypot(fx - hx, (fy - hy) * h / w)
                    if d < hr:
                        k = (1 - d / hr) ** 2 * 0.55
                        c = mix(c, tx, k)
                    row += bytes(c)
                rows.append(bytes(row))
        elif style == "waves":
            bands = 5 + rnd.randint(0, 3)
            phase = [rnd.uniform(0, 6.28) for _ in range(bands)]
            for y in range(h):
                row = bytearray()
                fy = y / h
                for x in range(w):
                    fx = x / w
                    v = 0.0
                    for i in range(bands):
                        v += math.sin(fx * (3 + i * 1.3) + fy * (4 - i * 0.5) + phase[i]) / bands
                    t = (v + 1) / 2
                    c = mix(bg, ac, t) if t < 0.72 else mix(ac, tx, (t - 0.72) * 2.2)
                    row += bytes(c)
                rows.append(bytes(row))
        elif style == "grid":
            step = max(24, int(min(w, h) / 14))
            r = step * 0.18
            for y in range(h):
                row = bytearray()
                gy = (y % step) - step / 2
                for x in range(w):
                    gx = (x % step) - step / 2
                    d = math.hypot(gx, gy)
                    fade = 0.35 + 0.65 * (x / w)
                    if d < r * fade:
                        c = ac if ((x // step + y // step) % 7) else tx
                    else:
                        c = mix(bg, ac, 0.08 * (y / h))
                    row += bytes(c)
                rows.append(bytes(row))
        else:  # blobs
            blobs = [(rnd.uniform(0.1, 0.9), rnd.uniform(0.1, 0.9), rnd.uniform(0.18, 0.4), ac if i % 3 else tx) for i in range(5)]
            for y in range(h):
                row = bytearray()
                fy = y / h
                for x in range(w):
                    fx = x / w
                    c = bg
                    for bx, by, br, bc in blobs:
                        d = math.hypot((fx - bx) * w / h, fy - by)
                        if d < br:
                            k = (1 - d / br)
                            k = k * k * (3 - 2 * k) * 0.85
                            c = mix(c, bc, k)
                    row += bytes(c)
                rows.append(bytes(row))

        def chunk(t, d):
            return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)

        raw = b"".join(b"\x00" + r for r in rows)
        png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 7)) + chunk(b"IEND", b"")
        name = "%s-%s-%dx%d.png" % (slug(inp.get("name_prefix") or "visual"), style, w, h)
        out = {"files": [file_entry(name, "image/png", png, "image", w, h, text_of(inp.get("caption"), 160, "Key visual, %s style." % style), style)], "count": 1, "palette": pal, "style": style}
        return out


result = DesignPngArt().run(inp)

# Output collection (the runner reads it here)
out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out.update(result)

Tests

Requires: python:3.9

  • waves

    A waves visual of 120 by 80 is one PNG of that size in the palette.

  • defaults

    No input paints the gradient at 1200 by 630.

  • clamped

    A size over 1600 a side is held to 1600, and an unknown style is the gradient.