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

ビジュアルスタジオを追加する

EventBrand + HUD chrome を適用した告知ビジュアルを組み、dev の /admin/visuals から asset-kit の exportPng で PNG を書き出せる状態にする

前提: new-event解説 ch.17解説 ch.20解説 ch.21解説 ch.22module: ビジュアル生成

@event/visuals(EventBrand トークン + フォーマットマトリクス + HUD chrome)と @event/asset-kit(DOM→PNG/PDF 書き出し)を配線し、admin ビジュアルジェネレータを立てる完全手順。対象 app を apps/<app>(例 apps/starter)とする。上から順に実行すれば完了する。

1. 依存と機能

apps/<app>/package.json の dependencies に(starter には既にある):

"@event/visuals": "*",
"@event/asset-kit": "*"

apps/<app>/next.config.tstranspilePackages"@event/visuals""@event/asset-kit" を足す。 apps/<app>/src/event.config.ts の features で visuals: true、そして FEATURES に descriptor を足す(本レシピ末尾を参照。requires 無し・必須 env 無し)。

2. ブランド + フォーマット設定

apps/<app>/src/lib/visuals.ts(イベントの唯一の reskin surface):

import {
  resolveBrand, createFormatRegistry, SNS_FORMATS, VENUE_FORMATS,
  type EventBrand, type Format,
} from "@event/visuals";

export const visualsBrand: EventBrand = resolveBrand({
  name: "starter",
  colors: { bg: "#0b0e13", bg2: "#141a24", accent: "#5ec8c0", accentDeep: "#2e6f6a", accentAlt: "#e0653c" },
  watermark: { glyph: "S", color: "rgba(94,200,192,0.06)", italic: true, weight: 800 },
});

export const STUDIO_FORMATS: Format[] = [
  ...SNS_FORMATS,
  ...VENUE_FORMATS.filter((f) => f.id === "panelA3" || f.id === "banner"),
];
export const studioFormatRegistry = createFormatRegistry(STUDIO_FORMATS);
export const DEFAULT_FORMAT_ID = "square";

resolveBrand は DEFAULT_BRAND(中立ダーク)に部分定義を浅くマージするので、色を数個 + watermark.glyph だけ書けば残りは既定で埋まる(第17章)。

3. admin ページ(server component — 認証 + feature ガードだけ)

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

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

export const dynamic = "force-dynamic";

export default async function VisualsPage() {
  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" }}>
      <h1 style={{ fontSize: 24, margin: "0 0 8px" }}>告知ビジュアル生成</h1>
      <VisualStudio />
    </main>
  );
}

4. 生成 UI("use client" — DOM 描画 + capture)

apps/<app>/src/app/admin/visuals/VisualStudio.tsx。カード本体 AnnounceCardプレビューと書き出しで同じ関数を使うのが要点(プレビューは transform:scale、書き出しは asset-kit が画面外 native 寸法で mount して撮る)。

"use client";
import { useState } from "react";
import { VisualChrome, brandCssVars, unit, type EventBrand, type Format } from "@event/visuals";
import { exportPng } from "@event/asset-kit/capture";
import { visualsBrand, STUDIO_FORMATS, DEFAULT_FORMAT_ID } from "@/lib/visuals";

type CardInput = { kicker: string; title: string; date: string; venue: string };

function AnnounceCard({ input, fmt, brand }: { input: CardInput; fmt: Format; brand: EventBrand }) {
  const u = unit(fmt.w, fmt.h);
  return (
    <div style={{ ...brandCssVars(brand), position: "relative", width: fmt.w, height: fmt.h, overflow: "hidden",
      background: "linear-gradient(158deg, var(--vis-bg), var(--vis-bg-2))", color: "var(--vis-fg)", fontFamily: "var(--vis-font-body)" }}>
      <VisualChrome w={fmt.w} h={fmt.h} brand={brand} />
      <div style={{ position: "absolute", inset: 0, zIndex: 2, display: "flex", flexDirection: "column",
        justifyContent: "space-between", padding: Math.round(92 * u), boxSizing: "border-box" }}>
        <div style={{ fontFamily: "var(--vis-font-mono)", fontSize: Math.round(22 * u), letterSpacing: "0.34em", textTransform: "uppercase", color: "var(--vis-accent)" }}>{input.kicker}</div>
        <h2 style={{ margin: 0, fontFamily: "var(--vis-font-jp)", fontWeight: 800, fontSize: Math.round(94 * u), lineHeight: 1.08, whiteSpace: "pre-wrap", color: "var(--vis-fg)" }}>{input.title}</h2>
        <div style={{ display: "flex", flexDirection: "column", gap: Math.round(12 * u) }}>
          <div style={{ width: Math.round(120 * u), height: Math.max(2, Math.round(3 * u)), background: "var(--vis-accent-alt)" }} />
          <div style={{ fontFamily: "var(--vis-font-mono)", fontSize: Math.round(30 * u), color: "var(--vis-fg-dim)" }}>{input.date}</div>
          <div style={{ fontFamily: "var(--vis-font-jp)", fontSize: Math.round(30 * u), color: "var(--vis-fg-dim)" }}>{input.venue}</div>
        </div>
      </div>
    </div>
  );
}

export function VisualStudio() {
  const brand = visualsBrand;
  const [kicker, setKicker] = useState("ANNOUNCEMENT");
  const [title, setTitle] = useState("EVENT MANUAL\nLAUNCH NIGHT");
  const [date, setDate] = useState("2026.09.12 FRI 19:00");
  const [venue, setVenue] = useState("ZFILMS STUDIO / 東京");
  const [formatId, setFormatId] = useState<string>(DEFAULT_FORMAT_ID);
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState<string | null>(null);
  const fmt = STUDIO_FORMATS.find((f) => f.id === formatId) ?? STUDIO_FORMATS[0];
  const input: CardInput = { kicker, title, date, venue };
  const scale = 380 / fmt.w;

  async function onExportPng() {
    setBusy(true); setStatus(null);
    try {
      const base = (title.split("\n")[0] || "visual").trim().replace(/\s+/g, "-").toLowerCase() || "visual";
      await exportPng(<AnnounceCard input={input} fmt={fmt} brand={brand} />, fmt, brand.colors.bg, base + "-" + fmt.id);
      setStatus("✓ PNG を書き出しました");
    } catch (e) { setStatus("✗ " + String(e)); } finally { setBusy(false); }
  }

  return (
    <div style={{ display: "flex", gap: 32, flexWrap: "wrap" }}>
      <div style={{ flex: "1 1 320px", display: "flex", flexDirection: "column", gap: 16 }}>
        <select value={formatId} onChange={(e) => setFormatId(e.target.value)}>
          {STUDIO_FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label ?? f.id} — {f.w}×{f.h}</option>)}
        </select>
        <input value={kicker} onChange={(e) => setKicker(e.target.value)} />
        <textarea value={title} onChange={(e) => setTitle(e.target.value)} rows={3} />
        <input value={date} onChange={(e) => setDate(e.target.value)} />
        <input value={venue} onChange={(e) => setVenue(e.target.value)} />
        <button type="button" onClick={onExportPng} disabled={busy}>{busy ? "書き出し中…" : "PNG 書き出し"}</button>
        {status && <span>{status}</span>}
      </div>
      <div style={{ width: 380, height: Math.round(fmt.h * scale), position: "relative", overflow: "hidden", border: "1px solid #333" }}>
        <div style={{ position: "absolute", top: 0, left: 0, width: fmt.w, height: fmt.h, transform: "scale(" + scale + ")", transformOrigin: "top left" }}>
          <AnnounceCard input={input} fmt={fmt} brand={brand} />
        </div>
      </div>
    </div>
  );
}

(starter に完成形があるので apps/starter/src/app/admin/visuals/** + src/lib/visuals.ts をコピーして app 名を直すのが最短。上のコードは説明用に装飾スタイルを削っている。)

5. 型チェックと検証

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

dev で確認(PNG 書き出しは client capture で完結するので 必須 env 無し):

  1. yarn workspace <app> dev
  2. /admin/visuals を開く(dev bypass でログイン不要)。左に入力・右にプレビュー
  3. タイトル/日付/会場を編集 → プレビューが即追従すること
  4. フォーマットを square → stories → panelA3 と切替 → プレビュー比率が変わること
  5. 「PNG 書き出し」→ event-manual-launch-night-square.png 等が実際にダウンロードされること(modern-screenshot が画面外で native 寸法で焼く)

完了条件: typecheck が通り、/admin/visuals でプレビューが描画され、書き出しボタンで PNG ファイルがダウンロードされる。

descriptor(event.config.ts FEATURES に足す)

{
  key: "visuals",
  label: "ビジュアルスタジオ",
  description: "EventBrand + HUD chrome の告知ビジュアル生成 → DOM→PNG/PDF 書き出し",
  nav: [{ id: "visual-studio", group: "ビジュアル", label: "ビジュアルスタジオ", href: "/admin/visuals", code: "VIS", order: 2 }],
  env: [],                       // PNG 書き出しは client capture で完結。Blob 永続化時のみ BLOB_READ_WRITE_TOKEN
  manualChapters: [17, 20, 21, 22],
  // requires なし(告知ビジュアルは 4S ライブデータ + ブランドトークンで完結し content に依存しない)
}