← GPT-AI-LP
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,
  ];
}