レシピ · 6手 · 1プロンプト実装

スピーカー切り抜き(背景除去)を追加する

画像アップロード → zero-dep canvas 背景除去 → 透過 PNG ダウンロードの /admin/cutout を配線し、production backend(MODNet / @imgly)への差し込み口を確保する

前提: new-event前提: add-visuals解説 ch.25module: ビジュアル生成module: スピーカー切り抜き

このレシピは @event/visuals の cutout モジュール(第25章 — CutoutBackend 契約 + zero-dep canvas フォールバック)を admin に配線し、画像アップロード → 背景除去 → 透過 PNG ダウンロードを動かす完全手順。 対象 app を apps/<app>(例では apps/starter)とする。上から順に実行すれば完了する。

追加依存はゼロ: 既定の canvasLumaKeyBackend はブラウザ内蔵の canvas 2D だけで動く本物の実装で、 dev で実際に透過が出る。髪の毛まで抜く production 品質は差し込み口(手順 5)に委ねる。

1. 前提確認

add-visuals 済みなら以下は全て済んでいる(このレシピでの追加作業なし):

cutout は専用 feature flag を持たず visuals フラグでガードする(@event/visuals 配下の admin ツールのため)。新しい descriptor も env も不要。

2. admin ページ(server — feature ガード + 認証のみ)

apps/<app>/src/app/admin/cutout/page.tsx:

import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { CutoutStudio } from "./CutoutStudio";

export const dynamic = "force-dynamic";

/**
 * スピーカー切り抜き — 画像をアップロードして背景を透過に落とし、透過 PNG を書き出す。
 *
 * server component は feature ガード + admin 認証だけを担い、切り抜き本体(canvas 背景除去 +
 * プレビュー + ダウンロード)は client 子コンポーネント <CutoutStudio> に委譲する。
 * @event/visuals 配下の admin ツールなので専用 feature flag は持たず、visuals フラグでガードする。
 */
export default async function CutoutPage() {
  if (!features.isEnabled("visuals")) notFound();
  const cookieValue = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookieValue)) redirect("/login");

  return (
    <main style={{ maxWidth: 1080, margin: "0 auto", padding: "40px 24px" }}>
      <p
        style={{
          fontFamily: "var(--mono)",
          fontSize: 11,
          letterSpacing: "0.14em",
          textTransform: "uppercase",
          color: "var(--accent)",
          margin: "0 0 10px",
        }}
      >
        cutout studio
      </p>
      <h1 style={{ fontSize: 24, margin: "0 0 8px" }}>スピーカー切り抜き</h1>
      <p style={{ fontSize: 13, opacity: 0.6, margin: "0 0 24px" }}>
        画像をアップロードして背景を透過に落とし、透過 PNG を書き出す。dev は依存ゼロの canvas
        カラーキー方式(<code>canvasLumaKeyBackend</code>)で動く。単色〜ほぼ単色背景に有効。髪境界まで
        抜く production 品質は MODNet / @imgly backend(<code>@event/visuals</code> に差し込み口あり・未同梱)へ。
      </p>
      <CutoutStudio />
    </main>
  );
}

starter は admin nav に cutout を載せず直接 URL(/admin/cutout)で開く。nav に出したければ visuals descriptor の nav 配列に足す(任意):

{ id: "cutout", group: "ビジュアル", label: "切り抜き", href: "/admin/cutout", code: "CUT", order: 3 },

3. 切り抜き UI("use client" — 処理は全て client で完結)

apps/<app>/src/app/admin/cutout/CutoutStudio.tsx。File を ImageBitmap にデコードし、 removeBackground(既定 = zero-dep canvas backend)に渡して透過 PNG Blob を得て Object URL で プレビューする。しきい値 / 羽化スライダを動かすたびに再処理。応答性のため maxSize=1600 に縮小して処理する:

"use client";

import { useEffect, useState, type CSSProperties } from "react";
import { removeBackground, canvasLumaKeyBackend } from "@event/visuals";

const PREVIEW_MAX = 420; // プレビュー枠の最大辺(px)
const WORK_MAX = 1600; // 処理前に縮小する最大辺(px)。スライダ再処理を軽く保つ。

/** 透過を見せるためのチェッカーボード背景(明暗2色のタイル)。 */
const checkerStyle: CSSProperties = {
  backgroundColor: "#d9d9d9",
  backgroundImage:
    "linear-gradient(45deg, #b7b7b7 25%, transparent 25%), linear-gradient(-45deg, #b7b7b7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #b7b7b7 75%), linear-gradient(-45deg, transparent 75%, #b7b7b7 75%)",
  backgroundSize: "16px 16px",
  backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0",
};

const labelStyle: CSSProperties = {
  display: "block",
  fontSize: 11,
  fontFamily: "var(--mono)",
  letterSpacing: "0.06em",
  textTransform: "uppercase",
  opacity: 0.6,
  margin: "0 0 6px",
};

const panelStyle: CSSProperties = {
  width: PREVIEW_MAX,
  height: PREVIEW_MAX,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  overflow: "hidden",
  border: "1px solid var(--line)",
  borderRadius: 4,
};

export function CutoutStudio() {
  const [file, setFile] = useState<File | null>(null);
  const [srcUrl, setSrcUrl] = useState<string | null>(null);
  const [outUrl, setOutUrl] = useState<string | null>(null);
  const [threshold, setThreshold] = useState(72);
  const [feather, setFeather] = useState(32);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 元画像のプレビュー URL(file が変わるたび貼り替え)。
  useEffect(() => {
    if (!file) {
      setSrcUrl(null);
      return;
    }
    const url = URL.createObjectURL(file);
    setSrcUrl(url);
    return () => URL.revokeObjectURL(url);
  }, [file]);

  // 背景除去(file / threshold / feather のいずれか変化で再処理)。
  useEffect(() => {
    if (!file) {
      setOutUrl(null);
      return;
    }
    let cancelled = false;
    setBusy(true);
    setError(null);
    (async () => {
      try {
        const bmp = await createImageBitmap(file);
        const blob = await removeBackground(bmp, canvasLumaKeyBackend, {
          threshold,
          feather,
          maxSize: WORK_MAX,
        });
        bmp.close();
        if (cancelled) return;
        const url = URL.createObjectURL(blob);
        setOutUrl((prev) => {
          if (prev) URL.revokeObjectURL(prev);
          return url;
        });
      } catch (e) {
        if (!cancelled) setError(String(e));
      } finally {
        if (!cancelled) setBusy(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [file, threshold, feather]);

  const baseName = file ? file.name.replace(/\.[^.]+$/, "") : "cutout";

  return (
    <div style={{ display: "flex", gap: 32, flexWrap: "wrap", alignItems: "flex-start" }}>
      {/* ── controls ── */}
      <div style={{ flex: "1 1 300px", minWidth: 260, display: "flex", flexDirection: "column", gap: 18 }}>
        <div>
          <label style={labelStyle} htmlFor="cut-file">
            画像を選択
          </label>
          <input
            id="cut-file"
            type="file"
            accept="image/*"
            onChange={(e) => setFile(e.target.files?.[0] ?? null)}
            style={{ fontSize: 13 }}
          />
        </div>

        <div>
          <label style={labelStyle} htmlFor="cut-threshold">
            しきい値 threshold — {threshold}
          </label>
          <input
            id="cut-threshold"
            type="range"
            min={0}
            max={200}
            step={1}
            value={threshold}
            onChange={(e) => setThreshold(Number(e.target.value))}
            style={{ width: "100%" }}
          />
          <p style={{ fontSize: 11, opacity: 0.5, margin: "4px 0 0" }}>
            背景キー色に近い画素をどこまで抜くか。大きいほど強く抜ける(被写体も削れやすい)。
          </p>
        </div>

        <div>
          <label style={labelStyle} htmlFor="cut-feather">
            羽化 feather — {feather}
          </label>
          <input
            id="cut-feather"
            type="range"
            min={1}
            max={120}
            step={1}
            value={feather}
            onChange={(e) => setFeather(Number(e.target.value))}
            style={{ width: "100%" }}
          />
          <p style={{ fontSize: 11, opacity: 0.5, margin: "4px 0 0" }}>
            境界のソフトさ。大きいほど輪郭が滑らか(がぼやける)。
          </p>
        </div>

        <div style={{ display: "flex", gap: 12, alignItems: "center", minHeight: 24 }}>
          {outUrl && !busy && (
            <a
              href={outUrl}
              download={`${baseName}-cutout.png`}
              style={{
                padding: "10px 18px",
                fontSize: 13,
                fontWeight: 600,
                color: "#0b0e13",
                background: "var(--accent)",
                border: "none",
                borderRadius: 4,
                textDecoration: "none",
              }}
            >
              透過 PNG をダウンロード
            </a>
          )}
          {busy && (
            <span style={{ fontSize: 12, fontFamily: "var(--mono)", opacity: 0.8 }}>処理中…</span>
          )}
          {error && (
            <span style={{ fontSize: 12, fontFamily: "var(--mono)", color: "var(--accent-alt, #e0653c)" }}>
              ✗ {error}
            </span>
          )}
        </div>
      </div>

      {/* ── previews(元 / 透過) ── */}
      <div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
        <div>
          <p style={labelStyle}>元画像</p>
          <div style={panelStyle}>
            {srcUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img src={srcUrl} alt="元画像" style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
            ) : (
              <span style={{ fontSize: 12, opacity: 0.4 }}>未選択</span>
            )}
          </div>
        </div>

        <div>
          <p style={labelStyle}>透過プレビュー</p>
          <div style={{ ...panelStyle, ...checkerStyle }}>
            {outUrl ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img src={outUrl} alt="切り抜き結果" style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
            ) : (
              <span style={{ fontSize: 12, color: "#555" }}>—</span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

4. canvasLumaKeyBackend の仕組み(何が起きているか)

cutout モジュールは「透過 PNG を返す」契約(CutoutBackend)だけを固定し、抜き方は差し替え可能:

import {
  removeBackground,
  canvasLumaKeyBackend,
  type CutoutBackend,
  type CutoutOpts,
} from "@event/visuals";

const png = await removeBackground(bitmap); // 既定 = canvas fallback
const png2 = await removeBackground(bitmap, canvasLumaKeyBackend, {
  threshold: 64, // 背景キー色からの RGB ユークリッド距離しきい値(0–441)。既定 72
  feather: 24,   // しきい値からのソフト境界幅(線形羽化)。既定 32
  maxSize: 1600, // 処理前に縮小する最大辺(応答性)。未指定なら等倍
  // keyColor: [255, 255, 255], // 背景キー色の明示指定。未指定なら枠(端)から自動推定
});

原理(依存ゼロ・ブラウザ内蔵 canvas 2D のみ):

  1. 入力(ImageBitmap / HTMLCanvasElement / Blob)を canvas に描き、ImageData(RGBA)を取り出す
  2. 枠 2px 内側をリング状にサンプルして背景キー色を推定(被写体は中央・背景は縁に多い、という

自然写真の前提。keyColor 明示時はそれを使う)

  1. 各画素とキー色の RGB ユークリッド距離 d を計算 — d ≤ threshold → alpha 0(完全透過)/

threshold < d < threshold+feather → alpha 0→255 を線形に羽化 / それ以上 → 元 alpha 保持(= 被写体)

  1. putImageData → canvas.toBlob("image/png") で透過 PNG Blob

得意: 単色〜ほぼ単色背景(スタジオ幕・白ホリ)。苦手: 複雑な背景・髪の毛の境界(→ 手順 5 の領分)。

ブラウザ専用: document / createImageBitmap / canvas を使うので必ず "use client" コンポーネントから呼ぶ。SSR で呼ぶと ensureBrowser が明示エラーを投げる。

5. production backend(MODNet / @imgly)への差し替え

髪の毛・半透明の毛先まで抜く alpha matting は重い ONNX モデル / WASM ランタイムが要るため パッケージには未同梱createModnetBackend / createImglyBackend差し込み口 (factory + 設定型)だけが export されており、依存を導入して実装を差すまでは呼ぶと明示的に throw する(動くのは canvas fallback だけ、と分かる形にしてある)。どちらも CutoutBackend 契約を満たすので、導入後は removeBackground の第 2 引数を差し替えるだけで UI は無改修。

実装の差し込み先は packages/visuals/src/cutout.ts(エンジンコードはパッケージ側に置く規約。 同ファイル末尾のコメントに以下の手順が原文で書いてある)。

(A) MODNet(onnxruntime-web)

  1. 依存追加: yarn workspace <app> add onnxruntime-web
  2. モデル配置: MODNet の .onnx(例 modnet_photographic_portrait_matting.onnx)を public/models/
  3. createModnetBackend の removeBackground を実装:

- 入力を config.inputSize(既定 512)四方にリサイズ → Float32 正規化(mean/std)した NCHW テンソル化 - ort.InferenceSession.create(config.modelUrl, { executionProviders }) で推論 (executionProviders 既定 ["wasm"]、WebGPU 環境は ["webgpu","wasm"]) - 出力 matte(1ch alpha, 0..1)を元寸法へ bilinear 拡大し、元画像 RGB × alpha で合成 - putImageData → 透過 PNG Blob を返す(canvas fallback と同じ出口)

  1. ※ onnxruntime-web は wasm/threads の COOP/COEP ヘッダや .wasm 配信設定が要る(Next config)

導入後の差し替え(CutoutStudio 側はこれだけ):

import { removeBackground, createModnetBackend } from "@event/visuals";

const backend = createModnetBackend({ modelUrl: "/models/modnet_photographic_portrait_matting.onnx" });
// removeBackground(bmp, backend, { maxSize: WORK_MAX }) に差し替え

(B) @imgly/background-removal

  1. 依存追加: yarn workspace <app> add @imgly/background-removal
  2. createImglyBackend の removeBackground を実装:
import { removeBackground as imglyRemove } from "@imgly/background-removal";

const out = await imglyRemove(inputBlobOrUrl, {
  publicPath: config.publicPath,
  output: { format: config.outputFormat },
});
return out; // 透過 PNG Blob
  1. ※ 初回にモデル資産(数十 MB)を publicPath から取得する。CDN 直リンクは避け自前配信を推奨

6. 型チェックと検証

yarn workspace <app> typecheck   # 対象 app にスコープ

dev で確認(env 不要・処理は全て client で完結):

  1. yarn workspace <app> dev
  2. /admin/cutout を開く(dev bypass でログイン不要)
  3. 単色〜ほぼ単色背景の人物画像をアップロード → 右にチェッカーボード上の透過プレビューが出る
  4. しきい値 / 羽化スライダを動かす → プレビューが再処理で追従する(WORK_MAX=1600 縮小で軽い)
  5. 「透過 PNG をダウンロード」→ <元ファイル名>-cutout.png が落ち、背景が透過になっている

完了条件: typecheck が通り、/admin/cutout でアップロード → 透過プレビュー → 透過 PNG ダウンロードまで動く。