CODE · 13.7 KB
src/visual-strategy.js
Workspace snapshot · 08/31 23:51
const VISUAL_PRIORITY = [
"hero",
"problem",
"risk",
"mechanism",
"usecase",
"differentiation",
"process",
"message",
"cta",
];
const VISUAL_ROLE = {
hero: "サービスの価値と利用者を一目で伝える、物語の入口となる実写または象徴的なキービジュアル",
problem: "解決前の現場で起きている摩擦を、誇張せず具体的な状況として見せるドキュメンタリーカット",
risk: "問題が連鎖していく時間や空間の変化を、複数の具体物で見せるエディトリアルカット",
mechanism: "入力・変換・成果の関係が直感で分かる、短いコピーを組み込んだ視覚的な仕組み図または俯瞰構成",
usecase: "対象ユーザーが実務で価値を得る瞬間を、手元・道具・環境まで具体的に見せる利用シーン",
differentiation: "固有の方法や資産が他の手段と異なる理由を、具体物の組み合わせで見せる編集写真",
process: "導入前から運用定着までの連続性を、短い工程コピーと同じ世界観の中で段階的に見せる工程ビジュアル",
message: "導入後に残る組織や顧客の変化を、余韻のある一場面で見せる全幅背景",
cta: "最後の行動を後押しする、明るく開けた余白のある全幅背景",
};
const VISUAL_SCENE = {
hero: "対象ユーザーとチームがサービスを使い、仕事の流れが変わり始める瞬間。主役は画面中央付近、コピーとフォームを置く左右は低密度",
problem: "一人の担当者だけに判断・資料・連絡が集中している瞬間。ほかの人物は入れず、表情と散らかった具体物で詰まりを見せる",
risk: "会議後もタスクが残り、空席・止まった資料・未整理の机が連鎖を示す静物中心の場面。人物の集合写真にしない",
mechanism: "ばらばらの入力資料が、整理されたカードと成果物へ変わる過程を真上から見せる。手元だけ可、顔や会議風景は入れない",
usecase: "対象ユーザーが一つの具体的な成果物を確認・修正・引き渡す手元の瞬間。2人以内、寄りの画角で実務を見せる",
differentiation: "固有の方法・テンプレート・知識・成果物が一つの運用体系として並ぶ編集写真。人物を主役にせず、具体物の質感で違いを示す",
process: "導入前・移行中・定着後の3段階を、同じ机と道具が徐々に整理される一続きの時間変化として見せる。顔や集合写真は使わない",
message: "仕事に余白が戻り、チームが次の企画へ向かう静かな余韻。人物は後ろ姿または遠景に留める",
cta: "視界が開けた明るい制作空間。行動ボタンと見出しを重ねられる大きな静かな余白",
};
const VISUAL_STYLE = {
hero: "日本のブランド広告として成立する高品質なエディトリアル実写。自然な人物、具体的な仕事道具、前景・中景・背景の奥行き",
problem: "やや緊張感のあるドキュメンタリー実写。寄りの画角、自然な疲労感、演技過剰にしない",
risk: "人物を主役にしないシネマティックな静物写真。光と空席で停滞を表現",
mechanism: "紙・カード・透明シート・道具を使った、実写のタクタイル・インフォグラフィック。抽象CGやUIではなく物理的な俯瞰構成",
usecase: "手元と成果物を主役にした日本の編集写真。浅い被写界深度、道具と所作が読める",
differentiation: "プロダクト・資料・運用資産の高品質なエディトリアル静物写真。素材感と秩序を主役にする",
process: "同一空間の時間変化を見せるタクタイルな連続写真。自然な光の移動と道具の配置で順序を示す",
message: "余韻のあるシネマティック実写。低彩度、広い空間、静かな希望",
cta: "明るく静かな建築・空間写真。低密度でコピーを邪魔しない",
};
const meaningful = (value) => String(value || "").trim();
const compactCopy = (value, limit) => {
const text = meaningful(value).replace(/\s+/gu, " ");
if (text.length <= limit) return text;
const sentence = text.split(/[。!?!?]/u).find(Boolean) || text;
return `${sentence.slice(0, limit).replace(/[、,\s]+$/u, "")}。`;
};
const copyForSection = (section, type, project) => {
const answers = project.answers || {};
const main = compactCopy(section.title || section.label, type === "hero" ? 24 : 20);
const sub = compactCopy(section.body || section.content, 46);
return {
main,
sub,
offer: type === "hero"
? compactCopy(answers.formalServiceName || answers.service, 28)
: "",
// フォーム・ボタン・リンクはHTMLで実装する。画像に偽の操作UIを描かせない。
cta: "",
};
};
const typeOf = (section = {}) => String(section.type || "content").toLowerCase();
export function hasMeaningfulImageBrief(section = {}) {
const brief = section.imageBrief;
return Boolean(
brief &&
typeof brief === "object" &&
[brief.scene, brief.subject, brief.style, brief.composition].some(meaningful),
);
}
export function selectVisualSections(sections = [], limit = 7) {
const list = Array.isArray(sections) ? sections : [];
const picked = [];
// まず役割ごとに1枚ずつ選び、同じ種類の連続生成を避ける。
for (const type of VISUAL_PRIORITY) {
const section = list.find((candidate) => typeOf(candidate) === type && !picked.includes(candidate));
if (!section) continue;
picked.push(section);
if (picked.length >= limit) return picked;
}
// 上限に余裕がある場合だけ、同種の2枚目を追加する。
for (const type of VISUAL_PRIORITY) {
for (const section of list.filter((candidate) => typeOf(candidate) === type && !picked.includes(candidate))) {
picked.push(section);
if (picked.length >= limit) return picked;
}
}
return picked;
}
const CONTENT_IMAGE_EXCLUDED = new Set(["form", "faq", "comparison", "pricing"]);
const layersForSection = (section) => {
const type = typeOf(section);
return CONTENT_IMAGE_EXCLUDED.has(type) ? ["background"] : ["background", "content"];
};
const compositionFor = (type, index) => {
if (type === "hero") return "横長の広告紙面。巨大な日本語と主役を中央付近で重ね、切れ・反復・色面の一つを効かせる。安全な左右分割や広い無地余白に逃げない";
if (["message", "cta"].includes(type)) return "横長の全幅背景。中央付近は主役、見出しを置く領域は低密度にし、上下トリミングにも耐える";
if (type === "problem") return "4:3。画像は左7カラムへ単独配置しカードを重ねない。主役を中央から左寄りへ置き、右端だけに偶然の白場を作らない。全面を写真または意図した色面で満たす";
if (type === "risk") return "4:3。画像は左6カラム、時間軸はHTMLで右6カラムに置く。時間の進行を光・机・資料の状態変化で見せ、画像内に偽の線やカードを作らない";
if (["mechanism", "process"].includes(type)) return "横長。3〜4段階の流れが左から右へ読める。画像内は具体物の配置と光で順序を示し、HTMLの比較面や工程線と競合する矢印・カードは描かない";
if (type === "usecase") return "4:3。画像は左8カラムの主役面として全面を使い、右4カラムのHTMLストーリーと接続する。人物・手元・成果物を中央寄りに置き、無目的な余白を残さない";
return index % 2
? "4:3。主役を左寄せにしつつフレーム全体を意図ある背景で満たす。HTML本文は画像外に置くため、広い無地余白を作らない"
: "4:3。主役を右寄せにしつつフレーム全体を意図ある背景で満たす。HTML本文は画像外に置くため、広い無地余白を作らない";
};
export function buildVisualPlan({ project = {}, sections = [], direction = {}, purpose = "implementation", limit = 36 } = {}) {
const selected = purpose === "tone"
? selectVisualSections(sections, 1)
: (Array.isArray(sections) ? sections : []).slice(0, 18);
const service = project.answers?.formalServiceName || project.answers?.service || project.name || "サービス";
const target = [project.answers?.industry, project.answers?.role, project.answers?.companySize]
.filter(Boolean)
.join(" / ") || "サービスの検討者";
return selected.flatMap((section, index) => layersForSection(section).map((layer) => {
const type = typeOf(section);
const brief = hasMeaningfulImageBrief(section) ? section.imageBrief : {};
const scene = type === "hero" && meaningful(brief.scene)
? `${brief.scene}。${VISUAL_SCENE.hero}`
: `${VISUAL_SCENE[type] || `${section.title || section.label || service}が意味する具体的な状況`}。セクション主張「${section.title || section.label || service}」を視覚化する`;
const subject = type === "hero" && meaningful(brief.subject)
? brief.subject
: `${VISUAL_SCENE[type] || section.body || section.content || VISUAL_ROLE[type] || "サービス価値が伝わる具体物"}`;
const style = type === "hero" && meaningful(brief.style)
? brief.style
: `${VISUAL_STYLE[type] || "高品質な編集写真"}。選択トンマナ「${direction.name || direction.concept || "ブランド方向性"}」の配色・素材感だけを継承する`;
const copy = copyForSection(section, type, project);
const background = layer === "background";
const instruction = background ? [
"Use case: ads-marketing。LPセクションの背景専用画像。文字、ロゴ、人物の顔、UI、カード、ボタン、図表は一切描かない。",
`選択トンマナ「${direction.name || "方向性"}」を厳守。配色:${(direction.palette || []).join(" / ")}。写真処理・素材感:${direction.type || ""}。`,
`章:${section.title || section.label || type}。場面:${scene}。主役コンテンツの背後で空気・奥行き・面の切替を作る。`,
"中央の情報を邪魔しない低コントラスト。単純なグラデーションや抽象円ではなく、同じ世界の光、紙、壁、机、素材のディテールで構成する。全面を意図ある質感で満たし、透かしなし。",
].join("").slice(0, 500) : [
"単体の広告バナーではなく、同じLPの一章として機能する完成済み日本語広告ビジュアルにする。エディトリアルに編集し、コピーとビジュアルは衝突・融合させる。",
"フォーム、入力欄、チェックボックス、ボタン、CTAバッジ、ナビゲーションは描かない。操作要素はHTML側で実装する。",
`役割:${VISUAL_ROLE[type] || "理解と記憶を助ける"}。場面:${scene}。`,
`選択トンマナ「${direction.name || "方向性"}」を厳守する。配色:${(direction.palette || []).join(" / ")}。型:${direction.type || ""}。`,
`表現:${style}。構図:${compositionFor(type, index)}。`,
"安全な左右分割、普通のオフィス写真、Canva・SaaS・情報商材テンプレ、セクションごとの別ブランド化は禁止。巨大文字、切れ、重なり、反復、色面のどれか一つで意図的な違和感を作る。",
"写真処理・書体感・余白規則を全章で共有し、日本語は短く大きく正確に組む。",
type === "hero" ? "ブランド名やサービス名を入れてよい。" : "ブランド名、ロゴ、CTA、ページ番号は入れない。章の主見出しと補助コピーだけを組み込む。",
["problem", "risk"].includes(type) ? "課題を暗い画面で表さず、明るさの中の緊張感で見せる。" : "",
["mechanism", "process"].includes(type) ? "物理的な資料・色面・反復で流れを見せ、架空UIは作らない。" : "",
meaningful(brief.avoid) ? `避ける:${brief.avoid}。` : "",
].filter(Boolean).join("").slice(0, 500);
return {
sectionId: section.id,
visualId: `${section.id}:${layer}`,
layer,
type,
serviceName: service,
target,
angle: `${section.title || section.label || service}。${direction.concept || ""}`.slice(0, 240),
direction: background ? "シンプル" : (["mechanism", "differentiation", "process", "message", "cta"].includes(type) ? "強コピー" : "人物写真"),
taste: "エディトリアル",
instruction,
copy,
textIntegrated: !background,
brandContext: [direction.name, direction.concept, project.answers?.brandAssets].filter(Boolean).join(" / ").slice(0, 1600),
documentPalette: meaningful(brief.palette) || (direction.palette || []).join(", "),
purpose: `${service}のLP内「${section.label || section.title || type}」の${background ? "背景レイヤー" : "コンテンツレイヤー"}。${VISUAL_ROLE[type] || "内容理解を助ける"}`.slice(0, 240),
width: 1536,
height: type === "message" || type === "cta" ? 768 : 1024,
};
})).slice(0, limit);
}
export function mergeVisualRecords(current = [], replacements = []) {
const next = Array.isArray(replacements) ? replacements.filter((item) => item?.sectionId && item?.src) : [];
const keyOf = (item) => item.visualId || `${item.sectionId}:${item.layer || "content"}`;
const replaced = new Set(next.map(keyOf));
return [
...(Array.isArray(current) ? current : []).filter((item) => item?.sectionId && item?.src && !replaced.has(keyOf(item))),
...next,
];
}