このレシピは @event/visuals の cutout モジュール(第25章 — CutoutBackend 契約 + zero-dep canvas フォールバック)を admin に配線し、画像アップロード → 背景除去 → 透過 PNG ダウンロードを動かす完全手順。 対象 app を apps/<app>(例では apps/starter)とする。上から順に実行すれば完了する。
追加依存はゼロ: 既定の canvasLumaKeyBackend はブラウザ内蔵の canvas 2D だけで動く本物の実装で、 dev で実際に透過が出る。髪の毛まで抜く production 品質は差し込み口(手順 5)に委ねる。
1. 前提確認
add-visuals 済みなら以下は全て済んでいる(このレシピでの追加作業なし):
@event/visualsがapps/<app>/package.jsondependencies とnext.config.tsの transpilePackages にあるsrc/event.config.tsの features でvisuals: true+ FEATURES に visuals descriptorsrc/lib/adminAuth.ts(new-event 時点で配線済み)
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 のみ):
- 入力(ImageBitmap / HTMLCanvasElement / Blob)を canvas に描き、ImageData(RGBA)を取り出す
- 枠 2px 内側をリング状にサンプルして背景キー色を推定(被写体は中央・背景は縁に多い、という
自然写真の前提。keyColor 明示時はそれを使う)
- 各画素とキー色の RGB ユークリッド距離 d を計算 — d ≤ threshold → alpha 0(完全透過)/
threshold < d < threshold+feather → alpha 0→255 を線形に羽化 / それ以上 → 元 alpha 保持(= 被写体)
- 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)
- 依存追加:
yarn workspace <app> add onnxruntime-web - モデル配置: MODNet の .onnx(例
modnet_photographic_portrait_matting.onnx)をpublic/models/へ 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 と同じ出口)
- ※ 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
- 依存追加:
yarn workspace <app> add @imgly/background-removal 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
- ※ 初回にモデル資産(数十 MB)を publicPath から取得する。CDN 直リンクは避け自前配信を推奨
6. 型チェックと検証
yarn workspace <app> typecheck # 対象 app にスコープ
dev で確認(env 不要・処理は全て client で完結):
yarn workspace <app> dev/admin/cutoutを開く(dev bypass でログイン不要)- 単色〜ほぼ単色背景の人物画像をアップロード → 右にチェッカーボード上の透過プレビューが出る
- しきい値 / 羽化スライダを動かす → プレビューが再処理で追従する(WORK_MAX=1600 縮小で軽い)
- 「透過 PNG をダウンロード」→
<元ファイル名>-cutout.pngが落ち、背景が透過になっている
完了条件: typecheck が通り、/admin/cutout でアップロード → 透過プレビュー → 透過 PNG ダウンロードまで動く。