webpkit 0.1.0

A pure-Rust WebP codec: lossless (VP8L) and lossy (VP8) behind one decode/encode API.
Documentation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>webpkit vs libwebp — comparison</title>
<style>
  :root {
    --bg: #f6f8fa; --panel: #ffffff; --panel-2: #f0f3f7;
    --ink: #12161d; --ink-2: #48525f; --ink-3: #7b8798;
    --line: #dde3ea; --line-2: #e9edf2;
    --accent: #0f8f8f; --accent-ink: #0a6d6d;
    --good: #1f9d57; --good-bg: #e2f3e8;
    --warn: #b9791f; --warn-bg: #f6ecd9;
    --bad: #cf4459; --bad-bg: #f7e0e4;
    --track: #e7ecf2;
    --shadow: 0 1px 2px rgba(18,22,29,.04), 0 4px 16px rgba(18,22,29,.05);
    --mono: ui-monospace, "Cascadia Code", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
    --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --bg: #0d1015; --panel: #151a21; --panel-2: #1b212a;
      --ink: #e7ebf1; --ink-2: #a7b1bf; --ink-3: #6f7b8b;
      --line: #262e39; --line-2: #202832;
      --accent: #35b9b9; --accent-ink: #56cccc;
      --good: #43c07d; --good-bg: #16301f;
      --warn: #d59a3f; --warn-bg: #322612;
      --bad: #e8697d; --bad-bg: #341a20;
      --track: #232b35;
      --shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.28);
    }
  }
  :root[data-theme="light"] {
    --bg: #f6f8fa; --panel: #ffffff; --panel-2: #f0f3f7;
    --ink: #12161d; --ink-2: #48525f; --ink-3: #7b8798;
    --line: #dde3ea; --line-2: #e9edf2;
    --accent: #0f8f8f; --accent-ink: #0a6d6d;
    --good: #1f9d57; --good-bg: #e2f3e8; --warn: #b9791f; --warn-bg: #f6ecd9;
    --bad: #cf4459; --bad-bg: #f7e0e4; --track: #e7ecf2;
    --shadow: 0 1px 2px rgba(18,22,29,.04), 0 4px 16px rgba(18,22,29,.05);
  }
  :root[data-theme="dark"] {
    --bg: #0d1015; --panel: #151a21; --panel-2: #1b212a;
    --ink: #e7ebf1; --ink-2: #a7b1bf; --ink-3: #6f7b8b;
    --line: #262e39; --line-2: #202832;
    --accent: #35b9b9; --accent-ink: #56cccc;
    --good: #43c07d; --good-bg: #16301f; --warn: #d59a3f; --warn-bg: #322612;
    --bad: #e8697d; --bad-bg: #341a20; --track: #232b35;
    --shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.28);
  }

  * { box-sizing: border-box; }
  html { -webkit-text-size-adjust: 100%; }
  body {
    margin: 0; background: var(--bg); color: var(--ink);
    font-family: var(--sans); line-height: 1.55;
    -webkit-font-smoothing: antialiased;
  }
  .wrap { max-width: 1060px; margin: 0 auto; padding: clamp(20px, 4vw, 52px) clamp(16px, 4vw, 40px) 80px; }

  header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
  .eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent-ink); font-weight: 600; }
  h1 { font-family: var(--mono); font-size: clamp(26px, 5vw, 40px); font-weight: 650; letter-spacing: -.01em; margin: 6px 0 4px; line-height: 1.08; text-wrap: balance; }
  h1 .vs { color: var(--ink-3); font-weight: 400; }
  .sub { color: var(--ink-2); max-width: 62ch; font-size: 15px; }
  .sub code { font-family: var(--mono); font-size: 13px; }
  .toggle { font-family: var(--mono); font-size: 12px; color: var(--ink-2); background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 7px 11px; cursor: pointer; white-space: nowrap; }
  .toggle:hover { border-color: var(--accent); color: var(--accent-ink); }
  .toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

  .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; margin: 26px 0 8px; }
  .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; box-shadow: var(--shadow); position: relative; overflow: hidden; }
  .tile::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--stripe, var(--accent)); }
  .tile .k { font-size: 12.5px; color: var(--ink-2); font-weight: 600; }
  .tile .v { font-family: var(--mono); font-size: 25px; font-weight: 650; margin: 6px 0 2px; letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
  .tile .note { font-size: 12.5px; color: var(--ink-3); }
  .tile.good { --stripe: var(--good); } .tile.warn { --stripe: var(--warn); } .tile.bad { --stripe: var(--bad); }
  .tile.good .v { color: var(--good); } .tile.bad .v { color: var(--bad); } .tile.warn .v { color: var(--warn); }

  section { margin-top: 40px; }
  .sec-head { display: flex; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--line); padding-bottom: 10px; margin-bottom: 20px; flex-wrap: wrap; }
  .sec-head h2 { font-family: var(--mono); font-size: 17px; font-weight: 640; margin: 0; }
  .sec-head .h-note { font-size: 13px; color: var(--ink-3); margin-left: auto; }

  .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 18px 20px 22px; margin-bottom: 18px; }
  .panel h3 { font-family: var(--mono); font-size: 13px; font-weight: 640; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); margin: 0 0 4px; }
  .panel .cap { font-size: 12.5px; color: var(--ink-3); margin: 0 0 16px; }
  .panel .cap code { font-family: var(--mono); font-size: 12px; }

  .bars { display: grid; gap: 9px; }
  .bar-row { display: grid; grid-template-columns: 72px 1fr 62px; align-items: center; gap: 12px; }
  .bar-row .lab { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); text-align: right; }
  .track { position: relative; height: 20px; background: var(--track); border-radius: 5px; overflow: hidden; }
  .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 5px 0 0 5px; }
  .fill.good { background: linear-gradient(90deg, color-mix(in srgb, var(--good) 78%, transparent), var(--good)); }
  .fill.warn { background: linear-gradient(90deg, color-mix(in srgb, var(--warn) 78%, transparent), var(--warn)); }
  .fill.bad  { background: linear-gradient(90deg, color-mix(in srgb, var(--bad) 78%, transparent), var(--bad)); }
  .parity { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--ink-3); opacity: .8; z-index: 3; }
  .parity::after { content: ""; position: absolute; top: -15px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 9.5px; color: var(--ink-3); }
  .bar-row .num { font-family: var(--mono); font-size: 13px; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
  .num.good { color: var(--good); } .num.warn { color: var(--warn); } .num.bad { color: var(--bad); }
  .axis { display: grid; grid-template-columns: 72px 1fr 62px; gap: 12px; margin-top: 8px; }
  .axis .ticks { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--mono); font-size: 10px; color: var(--ink-3); }
  .grouplab { grid-column: 1 / -1; text-align: left; color: var(--accent-ink); font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin: 10px 0 2px; }
  .grouplab .m { color: var(--ink-3); }
  .grouplab.first { margin-top: 0; }

  .scroll { overflow-x: auto; margin: 0 -4px; }
  table { border-collapse: collapse; width: 100%; font-size: 13px; min-width: 560px; }
  th, td { padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--line-2); font-variant-numeric: tabular-nums; }
  th { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); font-weight: 600; border-bottom: 1px solid var(--line); }
  td:first-child, th:first-child { text-align: left; font-family: var(--mono); color: var(--ink); }
  tbody tr:hover { background: var(--panel-2); }
  td.mono { font-family: var(--mono); }
  .chip { display: inline-block; font-family: var(--mono); font-size: 11.5px; font-weight: 600; padding: 2px 7px; border-radius: 20px; font-variant-numeric: tabular-nums; }
  .chip.good { color: var(--good); background: var(--good-bg); }
  .chip.warn { color: var(--warn); background: var(--warn-bg); }
  .chip.bad { color: var(--bad); background: var(--bad-bg); }

  .legend { display: flex; flex-wrap: wrap; gap: 14px 20px; font-size: 12.5px; color: var(--ink-2); margin-top: 14px; }
  .legend span { display: inline-flex; align-items: center; gap: 6px; }
  .dot { width: 10px; height: 10px; border-radius: 3px; }
  .dot.good { background: var(--good); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }

  .notes { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
  .note-card { background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 12px; padding: 14px 16px; }
  .note-card h4 { font-family: var(--mono); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--accent-ink); margin: 0 0 6px; }
  .note-card p { font-size: 13px; color: var(--ink-2); margin: 0; }
  .note-card code { font-family: var(--mono); font-size: 12px; background: var(--panel); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
  footer { margin-top: 40px; padding-top: 18px; border-top: 1px solid var(--line); font-size: 12px; color: var(--ink-3); font-family: var(--mono); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
  @media (max-width: 560px) {
    .bar-row, .axis { grid-template-columns: 58px 1fr 54px; gap: 8px; }
  }
  @media (prefers-reduced-motion: no-preference) {
    .fill { transition: width .4s ease; }
  }
</style>
</head>
<body>
<div class="wrap">
  <header>
    <div>
      <div class="eyebrow">Codec benchmark · generated locally</div>
      <h1>webpkit <span class="vs">vs</span> libwebp&nbsp;1.6.0</h1>
      <p class="sub">Pure-Rust WebP (<code>#![forbid(unsafe_code)]</code>, zero-dep) measured against the libwebp&nbsp;C reference — size, reconstruction quality, and in-process wall-clock speed across the synthetic content matrix at 256×256.</p>
    </div>
    <button class="toggle" id="themeBtn" type="button" aria-label="Toggle light or dark theme">◐ theme</button>
  </header>

  <div class="tiles" id="tiles"></div>

  <section>
    <div class="sec-head">
      <h2>Speed vs libwebp</h2>
      <span class="h-note">in-process wall-clock · ratio = ours ÷ libwebp · lower is better</span>
    </div>
    <div class="panel">
      <h3>Decode</h3>
      <p class="cap">Both decoders read the same VP8 bitstream. The gap is the price of <code>forbid(unsafe)</code>: libwebp runs hand-tuned SSE2/AVX/NEON; webpkit stays portable scalar Rust. Widest on flat content, where libwebp's SIMD IDCT/prediction gain most.</p>
      <div class="bars" id="decodeBars"></div>
      <div class="axis"><div class="ticks" id="decodeTicks"></div></div>
    </div>
    <div class="panel">
      <h3>Encode — effort-matched tiers</h3>
      <p class="cap">Our <b>Fast / Balanced / Best</b> against libwebp <b>method 1 / 4 / 6</b> at quality&nbsp;75. Fast is at parity (and can beat libwebp on patterned content); Best is competitive with libwebp's slowest tier; Balanced is the clear soft spot.</p>
      <div class="bars" id="encodeBars"></div>
      <div class="axis"><div class="ticks" id="encodeTicks"></div></div>
      <div class="legend">
        <span><i class="dot good"></i> ≤1.15× (parity/faster)</span>
        <span><i class="dot warn"></i> 1.15–2×</span>
        <span><i class="dot bad"></i> &gt;2× slower</span>
      </div>
    </div>
  </section>

  <section>
    <div class="sec-head">
      <h2>Size &amp; quality vs libwebp</h2>
      <span class="h-note">quality 75 · both encoders' output measured in-process</span>
    </div>
    <div class="panel">
      <h3>Lossy — VP8, our Best vs libwebp method 6</h3>
      <p class="cap">At the same quality our scale quantizes more aggressively, so read size and PSNR together (not rate-distortion-matched). Size ratio is ours ÷ libwebp bytes; ΔPSNR is ours − libwebp dB.</p>
      <div class="scroll">
        <table>
          <thead>
            <tr><th>content</th><th>ours&nbsp;bytes</th><th>libwebp&nbsp;bytes</th><th>size&nbsp;ratio</th><th>ours&nbsp;dB</th><th>libwebp&nbsp;dB</th><th>ΔPSNR</th></tr>
          </thead>
          <tbody id="lossyBody"></tbody>
        </table>
      </div>
    </div>
    <div class="panel">
      <h3>Lossless — VP8L, our Best vs libwebp lossless</h3>
      <p class="cap">The project's primary focus. Size ratio ours ÷ libwebp; the <b></b> tick is parity, shorter is smaller.</p>
      <div class="bars" id="losslessBars"></div>
      <div class="axis"><div class="ticks" id="losslessTicks"></div></div>
    </div>
  </section>

  <section>
    <div class="sec-head"><h2>Method &amp; caveats</h2></div>
    <div class="notes">
      <div class="note-card">
        <h4>In-process timing</h4>
        <p>libwebp linked via <code>libwebp-sys</code> and called in the same process — no CLI startup or file-IO skew. Median of warmed-up iterations.</p>
      </div>
      <div class="note-card">
        <h4>Fairness</h4>
        <p>Encode is effort-matched (tier ↔ method) at q75 on identical pixels; decode is timed on each codec's own output (VP8 decode is bit-exact, so cross-decoding is redundant).</p>
      </div>
      <div class="note-card">
        <h4>The SIMD gap</h4>
        <p>webpkit is safe scalar Rust by design. The decode gap is structural — closing it means autovectorization-friendly kernels, not a missed micro-op.</p>
      </div>
      <div class="note-card">
        <h4>Reproduce</h4>
        <p>Regenerate anytime with <code>just report-vs-libwebp</code>. Wall-clock is machine/thermal-dependent, so this report lives in <code>target/</code>, never committed.</p>
      </div>
    </div>
  </section>

  <footer>
    <span>webpkit · pure-Rust WebP</span>
    <span>corpus: webpkit-samples · libwebp-sys 1.6.0</span>
  </footer>
</div>

<script>
  (function () {
    "use strict";
    var DATA = __DATA_JSON__;

    var SPEED_MAX = 6.5;
    function speedClass(r) { return r <= 1.15 ? "good" : (r <= 2 ? "warn" : "bad"); }
    function ticks(el, labels) {
      el.innerHTML = labels.map(function (t) { return "<span>" + t + "</span>"; }).join("");
    }
    function speedRow(name, r) {
      var pct = Math.min(r / SPEED_MAX * 100, 100);
      var parity = 1 / SPEED_MAX * 100;
      var cls = speedClass(r);
      return '<div class="bar-row"><div class="lab">' + name + '</div>'
        + '<div class="track"><div class="fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div>'
        + '<div class="parity" style="left:' + parity.toFixed(1) + '%"></div></div>'
        + '<div class="num ' + cls + '">' + r.toFixed(2) + '×</div></div>';
    }

    // ---- summary tiles (derived) ----
    var decRatios = DATA.map(function (d) { return d.dec.ours / d.dec.lw; });
    var decMin = Math.min.apply(null, decRatios), decMax = Math.max.apply(null, decRatios);
    var llBest = Math.min.apply(null, DATA.map(function (d) { return d.ll.ob / d.ll.lb; }));
    var tiles = [
      ["good", "Lossless size (VP8L)", "≤ parity", "best case " + Math.round((1 - llBest) * 100) + "% smaller"],
      ["warn", "Lossy size / quality", "content-split", "wins on smooth, larger on sharp edges"],
      ["bad", "Decode speed", decMin.toFixed(1) + "" + decMax.toFixed(1) + "× slower", "safe scalar Rust vs libwebp SIMD"],
      ["warn", "Encode speed", "Fast ≈ · Best ≈", "Balanced is the soft spot"]
    ];
    document.getElementById("tiles").innerHTML = tiles.map(function (t) {
      return '<div class="tile ' + t[0] + '"><div class="k">' + t[1] + '</div>'
        + '<div class="v">' + t[2] + '</div><div class="note">' + t[3] + '</div></div>';
    }).join("");

    // ---- decode ----
    document.getElementById("decodeBars").innerHTML =
      DATA.map(function (d) { return speedRow(d.name, d.dec.ours / d.dec.lw); }).join("");
    ticks(document.getElementById("decodeTicks"), ["", "", "", "", "", "", ""]);

    // ---- encode tiers ----
    var tierNames = ["Fast", "Balanced", "Best"], tierM = ["1", "4", "6"];
    var eh = "";
    for (var i = 0; i < 3; i++) {
      eh += '<div class="grouplab' + (i === 0 ? ' first' : '') + '">' + tierNames[i]
        + ' <span class="m">↔ libwebp m' + tierM[i] + '</span></div>';
      eh += DATA.map(function (d) { return speedRow(d.name, d.enc.ours[i] / d.enc.lw[i]); }).join("");
    }
    document.getElementById("encodeBars").innerHTML = eh;
    ticks(document.getElementById("encodeTicks"), ["", "", "", "", "", "", ""]);

    // ---- lossy table ----
    document.getElementById("lossyBody").innerHTML = DATA.map(function (d) {
      var ratio = d.lossy.ob / d.lossy.lb;
      var rc = ratio <= 0.95 ? "good" : (ratio <= 1.1 ? "warn" : "bad");
      var dd = d.lossy.op - d.lossy.lp;
      var dc = dd >= 0.2 ? "good" : (dd >= -0.5 ? "warn" : "bad");
      var dstr = (dd >= 0 ? "+" : "") + dd.toFixed(2);
      return "<tr><td>" + d.name + "</td>"
        + '<td class="mono">' + d.lossy.ob.toLocaleString() + "</td>"
        + '<td class="mono">' + d.lossy.lb.toLocaleString() + "</td>"
        + '<td><span class="chip ' + rc + '">' + ratio.toFixed(2) + "×</span></td>"
        + '<td class="mono">' + d.lossy.op.toFixed(2) + "</td>"
        + '<td class="mono">' + d.lossy.lp.toFixed(2) + "</td>"
        + '<td><span class="chip ' + dc + '">' + dstr + "</span></td></tr>";
    }).join("");

    // ---- lossless bars (axis auto-scaled around parity) ----
    var llR = DATA.map(function (d) { return d.ll.ob / d.ll.lb; });
    var LO = Math.min(0.7, Math.min.apply(null, llR) - 0.03);
    var HI = Math.max(1.1, Math.max.apply(null, llR) + 0.03);
    document.getElementById("losslessBars").innerHTML = DATA.map(function (d) {
      var r = d.ll.ob / d.ll.lb;
      var pct = Math.max(0, Math.min((r - LO) / (HI - LO) * 100, 100));
      var parity = (1 - LO) / (HI - LO) * 100;
      var cls = r <= 0.97 ? "good" : (r <= 1.005 ? "warn" : "bad");
      return '<div class="bar-row"><div class="lab">' + d.name + '</div>'
        + '<div class="track"><div class="fill ' + cls + '" style="width:' + pct.toFixed(1) + '%"></div>'
        + '<div class="parity" style="left:' + parity.toFixed(1) + '%"></div></div>'
        + '<div class="num ' + cls + '">' + (r * 100).toFixed(1) + '%</div></div>';
    }).join("");
    ticks(document.getElementById("losslessTicks"),
      [LO.toFixed(2) + "×", "1.0×", HI.toFixed(2) + "×"]);

    // ---- theme toggle ----
    document.getElementById("themeBtn").addEventListener("click", function () {
      var cur = document.documentElement.getAttribute("data-theme");
      if (!cur) cur = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
      document.documentElement.setAttribute("data-theme", cur === "dark" ? "light" : "dark");
    });
  })();
</script>
</body>
</html>