@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.ts の transpilePackages に "@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 無し):
yarn workspace <app> dev/admin/visualsを開く(dev bypass でログイン不要)。左に入力・右にプレビュー- タイトル/日付/会場を編集 → プレビューが即追従すること
- フォーマットを square → stories → panelA3 と切替 → プレビュー比率が変わること
- 「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 に依存しない)
}