event-manual / chapter 39

アセットストアと一括ダウンロード

@event/asset-kit

@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

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) の手順が事故対策そのものである:

  1. 画面外に native 寸法(transform: none)で mount — transform:scale されたプレビューの直撮りはサブピクセル丸めで文字がズレる
  2. 2フレーム待ち → document.fonts.ready
  3. 画像の data URI inline 化 — CORS の無い外部画像は imgProxy 経由で fetch し、リトライ3回 + 併走上限6で確実に焼く。本番でだけロゴ・顔写真が抜ける真因は serverless コールドスタートへの同時 fetch 殺到であり、この併走制御が根治策
  4. 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");

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)も提供する。