今天我們把首頁做成一個單頁 Demo,選圖、三顆按鈕(灰階/模糊/銳化)、即時顯示尺寸與處理時間,並且沿用前幾天做好的零拷貝資料流與錯誤訊息協議。可以把下面整包貼進新資料夾,輕輕鬆鬆。
清晰的解釋以下流程:
.wasm;2. 怎麼把像素丟進去、把結果拿回來;3. 這一來一回要花多少時間。package.json(固定 ESM,鎖住入口與 dev script)
{
  "name": "rustwasm-test",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "img-wasm": "^0.1.0"
  },
  "devDependencies": {
    "vite": "^5.4.0",
    "typescript": "^5.4.0"
  }
}
接著放兩個檔:index.html 與 main.ts。注意:wasm-pack 預設會輸出 rusttest_wasm_bg.wasm;如果發佈時改過 --out-name,下方 ?url 路徑要一起改。
index.html選圖/三顆按鈕/資訊列
<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>img-wasm demo</title>
    <style>
      body { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; }
      #toolbar { display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
      #stats { margin-top:8px; color:#555; }
      canvas { display:block; margin-top:12px; max-width:100%; border-radius:8px; }
      button:disabled { opacity:.5; cursor:not-allowed; }
      .pill { padding:2px 8px; border-radius:999px; background:#f3f3f3; margin-right:8px; }
      #error { margin-top:8px; color:#b00020; white-space:pre-wrap; }
    </style>
  </head>
  <body>
    <div id="toolbar">
      <input id="pick" type="file" accept="image/*" />
      <button id="btn-gray" disabled>灰階</button>
      <button id="btn-blur" disabled>模糊 r=2</button>
      <button id="btn-sharp" disabled>銳化</button>
      <span class="pill" id="dim">–</span>
      <span class="pill" id="timing">–</span>
      <span class="pill" id="wasm">WASM:載入中…</span>
    </div>
    <div id="stats"></div>
    <div id="error"></div>
    <canvas id="cv"></canvas>
    <script type="module" src="/main.ts"></script>
  </body>
</html>
main.ts這份程式做了四件事:
.wasm 檔;{code,message,hint} 呈現。import init, {
  memory,
  ensure_buffer,
  buffer_ptr,
  buffer_len,
  load_pixels,
  run_pipeline_inplace,
} from 'img-wasm'
import wasmUrl from 'rustwasm-test/rustwasm-test_bg.wasm?url'
await init({ module_or_path: wasmUrl })
const cv = document.querySelector<HTMLCanvasElement>('#cv')!
const ctx = cv.getContext('2d', { willReadFrequently: true })!
const pick = document.querySelector<HTMLInputElement>('#pick')!
const btnGray = document.querySelector<HTMLButtonElement>('#btn-gray')!
const btnBlur = document.querySelector<HTMLButtonElement>('#btn-blur')!
const btnSharp = document.querySelector<HTMLButtonElement>('#btn-sharp')!
const pillDim = document.querySelector<HTMLSpanElement>('#dim')!
const pillTiming = document.querySelector<HTMLSpanElement>('#timing')!
const pillWasm = document.querySelector<HTMLSpanElement>('#wasm')!
const errorEl = document.querySelector<HTMLDivElement>('#error')!
pillWasm.textContent = 'WASM:ready'
let W = 0, H = 0
function toJsError(e: any) {
  const code = e?.code ?? e?.error?.code ?? 'E_UNKNOWN'
  const message = e?.message ?? e?.error?.message ?? String(e)
  const hint = e?.hint ?? e?.error?.hint
  const err = new Error(message) as Error & { code: string; hint?: string }
  err.code = code
  if (hint) (err as any).hint = hint
  return err
}
function setButtons(enabled: boolean) {
  btnGray.disabled = btnBlur.disabled = btnSharp.disabled = !enabled
}
function showDims() {
  pillDim.textContent = W ? `${W}×${H}` : '–'
}
function measure<T>(fn: () => T) {
  const t0 = performance.now()
  const ret = fn()
  const ms = performance.now() - t0
  return { ret, ms }
}
// 載圖 → 畫到 canvas
pick.onchange = () => {
  const file = pick.files?.[0]; if (!file) return
  const url = URL.createObjectURL(file)
  const img = new Image()
  img.onload = () => {
    W = img.naturalWidth; H = img.naturalHeight
    cv.width = W; cv.height = H
    ctx.drawImage(img, 0, 0)
    showDims()
    setButtons(true)
    URL.revokeObjectURL(url)
    errorEl.textContent = ''
    pillTiming.textContent = '–'
  }
  img.src = url
}
// 把像素一次放進 WASM,算完再一次貼回
async function runInplace(ops: unknown[]) {
  if (!W || !H) return
  errorEl.textContent = ''
  try {
    const imgData = ctx.getImageData(0, 0, W, H)
    // JS→WASM
    const bytes = new Uint8Array(imgData.data.buffer) // 與 ClampedArray 共用底層
    ensure_buffer(bytes.length)
    load_pixels(bytes)
    // 在 WASM 內跑完整管線
    const { ms } = measure(() => {
      run_pipeline_inplace(W, H, ops)
    })
    // WASM → JS
    const ptr = buffer_ptr()
    const len = buffer_len()
    const view = new Uint8Array((memory as WebAssembly.Memory).buffer, ptr, len)
    imgData.data.set(view)
    ctx.putImageData(imgData, 0, 0)
    pillTiming.textContent = `${ms.toFixed(2)} ms`
  } catch (e) {
    const err = toJsError(e)
    errorEl.textContent = `[${(err as any).code}] ${err.message}${(err as any).hint ? '\n' + (err as any).hint : ''}`
  }
}
// 灰階/模糊/銳化
btnGray.onclick = () => runInplace([{ kind: 'grayscale' }])
btnBlur.onclick  = () => runInplace([{ kind: 'blur', r: 2 }])
btnSharp.onclick = () => runInplace([{ kind: 'conv3x3', k: [0,-1,0, -1,5,-1, 0,-1,0] }])
啟動
pnpm dev
為什麼這樣長相?
wasmUrl用?url讓 bundler 在建置時把.wasm當靜態資源處理,瀏覽器可直接抓。
ensure_buffer → load_pixels → run_pipeline_inplace → buffer_ptr/len這條鏈,確保整段只發生兩次拷貝(一次進、一次出),符合 Day 16 的零拷貝資料流目標。- 每次呼叫
ensure_buffer之後 再用buffer_ptr/len取 view,避免memory.grow造成舊視圖失效(Day 19 已經把這條規則寫進錯誤碼的 hint)。