@event/asset-kit は barrel を持たず、client bundle にサーバ依存を混入させないため4つの entrypoint に分割されている: /server(Blob store)・/capture(DOM→PNG/PDF/.ai)・/bulk(worker-pool ZIP)・/image(sharp variants)。
createAssetStore(/server)
Vercel Blob への書き込みを規約化する。パスは v<version>/<folder>/<timestamp>-<rand>-<safeName> の追記専用。
const store = createAssetStore({ version: "v3" }); // BLOB_READ_WRITE_TOKEN は env から
await store.putFile("uploads", file);
await store.putBytes("posters", "a1.png", bytes, "image/png");
await store.rehostByHash("logos", bytes, "image/png", ".png"); // 冪等
await store.appendRecord("survey-responses", record); // 1 record = 1 blob
rehostByHash— キーがsha256(bytes)先頭24桁なので同一バイト列は同一 URL に落ちる。失効する外部 URL(4S avatar / Notion 画像)の恒久化に使う。存在確認を byte-hash に寄せているのは、Blob のlist()が eventual consistent で直近書き込みが見えないことがあるため —list()に依存しない設計が規約であるappendRecord— PII を git に置かない追記専用 JSON record(サーベイ回答・見積等)
createUploadHandler({ store, isAuthed }) を POST /api/admin/upload に mount すると、@event/cms の image フィールドがそのまま使える。MIME allowlist(png/jpeg/webp/gif/avif/svg)、8MB 上限、folder サニタイズ、?variants=true での makeVariants hook を持つ。variants の実体 uploadImageWithVariants(/image)は thumb 320 / card 640 / full 1280 の WebP を sharp(dynamic import — 使う route だけに載る)で生成し、EXIF orientation を反映、gif は passthrough、SVG は stored XSS 防止のため保存段階で拒否する。
capture engine(/capture)— modern-screenshot 一択の理由
3リポには modern-screenshot / html2canvas-pro / html-to-image の3種の rasterizer があった。統一版は modern-screenshot を第一候補にする。foreignObject 方式はブラウザ自身のレンダラで描くため画面と一致する。html2canvas は CSS を再実装しているため、字形・行送り・グラデーションが画面と乖離する。失敗時のみ html2canvas-pro に fallback する(壊れて出ないよりマシ)。
captureCanvas(element, fmt, bg, config) の手順が事故対策そのものである:
- 画面外に native 寸法(transform: none)で mount — transform:scale されたプレビューの直撮りはサブピクセル丸めで文字がズレる
- 2フレーム待ち →
document.fonts.ready - 画像の data URI inline 化 — CORS の無い外部画像は
imgProxy経由で fetch し、リトライ3回 + 併走上限6で確実に焼く。本番でだけロゴ・顔写真が抜ける真因は serverless コールドスタートへの同時 fetch 殺到であり、この併走制御が根治策 - img の load 完了を待ってから rasterize
useLayoutEffect のテキストフィットに依存する DOM は、React 裏レンダだと計算が間に合わず崩れる。その場合は表示済み要素を clone して撮る captureElementCanvas(source, fmt, bg) を使う。
bulkZip(/bulk)
~7箇所に再実装されていた一括 ZIP ループの統合版:
const { blob, ok, failed } = await bulkZip(
sessions.map((s) => ({
path: `cards/${s.id}.png`,
data: () => fetch(`/session-card/png?id=${s.id}`).then((r) => r.blob()),
})),
{ concurrency: 4, onProgress: (done, total) => setProgress(done / total) },
);
if (blob) downloadBlob(blob, "cards.zip");
- 並列上限つき worker pool(既定4)
- 失敗 job はスキップして続行(1枚の失敗で全体を落とさない)。
failed[]に理由が残る - ファイル名衝突は
uniqPathが"a (2).png"連番で回避、sanitizeFileNameで OS 禁止文字を除去 - 全滅時は ZIP を作らない(空 ZIP ガード —
blob: null) readmeオプションで ZIP 直下に README.txt を注入できる
exportPdf / exportAi
exportPdf は capture した canvas を PNG(可逆)のまま pdf-lib で物理サイズ PDF に埋め込む。JPEG だと低 opacity の透かし階調が潰れて「プレビューと違う」事故になる。fmt.pageMm(既定 A4 = [210, 297])で A1 等の大判も1ページで入稿できる。
exportAi はトリックである: 実体は素の PDF を .ai 拡張子・application/illustrator で保存するだけ。Illustrator は PDF 互換データをそのままレンダリングするため、プレビュー通りに開ける。逆に「台紙 .ai に画像を差し込む」方式は AIPrivateData(ネイティブデータ)が優先されてデザインが表示されない。ほかに copyPngToClipboard(ClipboardItem に Promise<Blob> を渡して user gesture を保持。Figma へ Cmd+V)も提供する。