<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ホロライブ AIプロンプト生成サイト</title>
<meta name="description" content="ホロライブのメンバー向けに、画像生成AIで使えるプロンプトをワンクリック作成。日英対応/テンプレ/ネガティブ/コピーボタン/履歴保存。" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Noto Sans JP', 'ui-sans-serif', 'system-ui'] },
colors: {
skin: {
base: '#0b1020',
card: '#0f152b',
accent: '#60a5fa',
soft: '#a5b4fc'
}
},
boxShadow: {
glass: '0 8px 30px rgba(0,0,0,.35)'
}
}
}
}
</script>
<style>
html,body{height:100%}
.glass{backdrop-filter: saturate(140%) blur(12px); background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
.chip{border:1px solid rgba(255,255,255,.15);}
.mono{font-feature-settings: "ss01" on, "cv01" on;}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-skin-base to-black text-slate-100 font-sans">
<header class="sticky top-0 z-30 border-b border-white/10 bg-slate-900/70 glass">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center gap-4">
<div class="size-10 rounded-2xl bg-skin-accent/20 grid place-items-center shadow-glass">
<span class="text-skin-accent font-black">AI</span>
</div>
<div>
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">ホロライブ AIプロンプト生成</h1>
<p class="text-slate-300 text-sm">メンバーを選ぶ→テンプレを選ぶ→生成! 日/英・ネガティブ・履歴・コピペ完備</p>
</div>
<div class="ms-auto flex items-center gap-3">
<label class="flex items-center gap-2 text-sm"><input id="langToggle" type="checkbox" class="accent-skin-accent"> 英語で出力</label>
<button id="randomBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">ランダム</button>
<button id="resetBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">リセット</button>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 py-6 grid lg:grid-cols-2 gap-6">
<!-- 左:入力パネル -->
<section class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg mb-3">1) メンバー & スタイル設定</h2>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">メンバー</label>
<div class="flex gap-2">
<input id="memberSearch" type="text" placeholder="名前/世代/特徴で検索" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div class="mt-2 max-h-48 overflow-auto pr-1">
<ul id="memberList" class="space-y-1"></ul>
</div>
</div>
<div>
<label class="block text-sm mb-1">テンプレート (用途)</label>
<select id="templateSelect" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="portrait">高品質ポートレート</option>
<option value="fullbody">全身イラスト</option>
<option value="chibi">デフォルメ/ちびキャラ</option>
<option value="vtuber">VTuber配信サムネ</option>
<option value="live2d">Live2D立ち絵</option>
<option value="vrchat">VRChat アバター風</option>
<option value="manga">モノクロ漫画コマ</option>
<option value="poster">キービジュアル/ポスター</option>
<option value="landscape">背景&小さめ人物</option>
</select>
<div class="grid grid-cols-2 gap-2 mt-3">
<label class="text-sm flex items-center gap-2"><input id="nsfwSafe" type="checkbox" checked class="accent-skin-accent"> NSFW禁止</label>
<label class="text-sm flex items-center gap-2"><input id="useNeg" type="checkbox" checked class="accent-skin-accent"> ネガティブ付与</label>
<label class="text-sm flex items-center gap-2"><input id="addPose" type="checkbox" class="accent-skin-accent"> ポーズ指定</label>
<label class="text-sm flex items-center gap-2"><input id="addCamera" type="checkbox" class="accent-skin-accent"> カメラ/レンズ</label>
</div>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4 mt-5">
<div>
<label class="block text-sm mb-1">画風プリセット</label>
<select id="stylePreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="anime">アニメ塗り(鮮やか)</option>
<option value="semiReal">セミリアル</option>
<option value="watercolor">水彩/やわらか</option>
<option value="celshade">セルルック</option>
<option value="painterly">厚塗り/絵画風</option>
<option value="3dtoon">3Dトゥーン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">照明/雰囲気</label>
<select id="moodPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="soft">柔らかい光 / やさしい雰囲気</option>
<option value="dramatic">ドラマチック / リムライト</option>
<option value="studio">スタジオ照明 / クリーン</option>
<option value="sunset">夕焼け / ゴールデンアワー</option>
<option value="night">夜景 / ネオン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">解像度・比率</label>
<select id="aspectPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="square">正方形(1024×1024)</option>
<option value="portrait">縦長(768×1152)</option>
<option value="landscape">横長(1152×768)</option>
<option value="thumb">サムネ(1280×720)</option>
</select>
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">衣装・小物(任意)</label>
<input id="outfitInput" type="text" placeholder="例: 制服, ライブ衣装, 私服, 王冠, マント" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">背景・シーン(任意)</label>
<input id="bgInput" type="text" placeholder="例: ステージ, 星空, 教室, サイバーシティ" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">モデル/LoRA(任意)</label>
<input id="modelInput" type="text" placeholder="例: anime-v4, AnythingV5, holo_member_lora:0.8" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">追加キーワード(任意)</label>
<input id="extraInput" type="text" placeholder="例: 1girl, detailed eyes, dynamic lighting" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button id="generateBtn" class="px-5 py-3 rounded-2xl bg-skin-accent text-black font-bold hover:opacity-90">生成</button>
<button id="copyBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">コピー</button>
<button id="saveBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">履歴に保存</button>
<button id="exportBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON書き出し</button>
<button id="importBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON読込</button>
</div>
</section>
<!-- 右:出力/履歴 -->
<section class="space-y-6">
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg">2) 出力(Positive / Negative / メタ)</h2>
<div class="mt-3 grid gap-3">
<label class="text-sm">Positive Prompt</label>
<textarea id="posOut" rows="6" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここに生成結果"></textarea>
<label class="text-sm">Negative Prompt</label>
<textarea id="negOut" rows="4" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここにネガティブ"></textarea>
<div class="grid md:grid-cols-3 gap-3">
<div>
<label class="text-sm">解像度</label>
<input id="metaRes" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" readonly>
</div>
<div>
<label class="text-sm">推奨CFG/Steps</label>
<input id="metaCfg" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="CFG 6-8 / Steps 28-36">
</div>
<div>
<label class="text-sm">推奨Sampler</label>
<input id="metaSampler" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="DPM++ 2M Karras">
</div>
</div>
</div>
</div>
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<div class="flex items-center gap-3 mb-3">
<h2 class="font-bold text-lg">3) 履歴</h2>
<button id="clearHist" class="ms-auto px-3 py-1.5 text-sm rounded-xl bg-white/10 border border-white/10 hover:bg-white/20">全削除</button>
</div>
<div id="history" class="space-y-3 max-h-72 overflow-auto pr-1"></div>
</div>
</section>
</main>
<footer class="max-w-6xl mx-auto px-4 pb-10 text-slate-400 text-sm">
<div class="glass rounded-2xl p-4 border border-white/10">
<p class="leading-relaxed">注意:本ツールは各メンバーの公式ガイドラインを尊重し、成人向けや誹謗中傷の内容を禁止します。商用利用や二次創作ルールは各社ポリシーをご確認ください。</p>
</div>
</footer>
<script>
// --- データセット(抜粋・追加可) ---
const MEMBERS = [
// JP
{ key:'Tokino Sora', gen:'JP0', color:'#5bc0eb', motifs:['星','リボン'], traits:['清楚','やさしい'], outfit:['セーラー風','リボン'], keywords:['idol','first gen','blue ribbon'], en:true },
{ key:'Shirakami Fubuki', gen:'JP1', color:'#ffffff', motifs:['狐','尻尾'], traits:['元気','明るい'], outfit:['セーラー','マフラー'], keywords:['fox girl','white hair','animal ears'] },
{ key:'Natsuiro Matsuri', gen:'JP1', color:'#f4a261', motifs:['祭','ポニーテール'], traits:['活発','いたずら'], outfit:['体操服','浴衣'], keywords:['cheerful','ponytail'] },
{ key:'Minato Aqua', gen:'JP2', color:'#b388ff', motifs:['メイド','ヘッドドレス'], traits:['ドジっ子','ピンク紫髪'], outfit:['メイド服'], keywords:['maid','twin tails'] },
{ key:'Shion', gen:'JP2', color:'#c084fc', motifs:['魔法','三角帽'], traits:['小悪魔','ツリ目'], outfit:['魔女服'], keywords:['witch','purple hair'] },
{ key:'Nakiri Ayame', gen:'JP2', color:'#ef4444', motifs:['鬼角','和装'], traits:['クール','凛'], outfit:['巫女風'], keywords:['oni horns','kimono style'] },
{ key:'Ookami Mio', gen:'GAMERS', color:'#111827', motifs:['狼','耳'], traits:['頼れる','落ち着き'], outfit:['黒衣装'], keywords:['wolf girl','black outfit'] },
{ key:'Houshou Marine', gen:'JP3', color:'#ef4444', motifs:['海賊帽','錨'], traits:['情熱','大人っぽい'], outfit:['海賊衣装'], keywords:['pirate','captain hat'] },
{ key:'Usada Pekora', gen:'JP3', color:'#93c5fd', motifs:['うさ耳','人参'], traits:['やんちゃ','元気'], outfit:['うさぎパーカー'], keywords:['bunny ears','carrot'] },
{ key:'Shiranui Flare', gen:'JP3', color:'#f59e0b', motifs:['エルフ','耳'], traits:['包容','陽気'], outfit:['冒険者'], keywords:['elf ears','adventurer'] },
{ key:'Shirogane Noel', gen:'JP3', color:'#9ca3af', motifs:['騎士','鎧'], traits:['真面目','力持ち'], outfit:['鎧','マント'], keywords:['knight armor','silver hair'] },
{ key:'Hoshimachi Suisei', gen:'INoNaka/JP', color:'#60a5fa', motifs:['星','アイドル'], traits:['クール','アイドル'], outfit:['青系衣装'], keywords:['star motif','blue idol'] },
{ key:'Amane Kanata', gen:'JP4', color:'#60a5fa', motifs:['天使','羽'], traits:['ストイック'], outfit:['白蒼衣装'], keywords:['angel wings','halo'] },
{ key:'Kiryu Coco', gen:'JP4', color:'#f97316', motifs:['ドラゴン','角'], traits:['豪快'], outfit:['ドラゴンモチーフ'], keywords:['dragon horns','orange hair'] },
{ key:'Tsunomaki Watame', gen:'JP4', color:'#facc15', motifs:['羊','リボン'], traits:['ふわふわ'], outfit:['羊モチーフ'], keywords:['sheep girl','blonde'] },
{ key:'Himemori Luna', gen:'JP4', color:'#f472b6', motifs:['姫','王冠'], traits:['キュート'], outfit:['姫ドレス'], keywords:['princess crown','pink'] },
{ key:'Laplus Darknesss', gen:'HoloX', color:'#6d28d9', motifs:['悪魔','マント'], traits:['いたずら'], outfit:['黒紫コート'], keywords:['devilish','hooded coat'] },
{ key:'Takane Lui', gen:'HoloX', color:'#ef4444', motifs:['スパイ','赤黒'], traits:['クール'], outfit:['スーツ風'], keywords:['spy','red black'] },
{ key:'Sakamata Chloe', gen:'HoloX', color:'#94a3b8', motifs:['シャチ','フード'], traits:['あざとい'], outfit:['白黒フード'], keywords:['orca hoodie','monochrome'] },
{ key:'Hakui Koyori', gen:'HoloX', color:'#fb7185', motifs:['研究','ピンク'], traits:['好奇心'], outfit:['研究白衣'], keywords:['lab coat','pink hair'] },
{ key:'Kazama Iroha', gen:'HoloX', color:'#86efac', motifs:['忍者','刀'], traits:['素直'], outfit:['忍装束'], keywords:['ninja','katana'] },
// EN
{ key:'Mori Calliope', gen:'EN Myth', color:'#ef4444', motifs:['鎌','死神'], traits:['クール'], outfit:['黒×ピンク'], keywords:['reaper scythe','rapper'] },
{ key:'Gawr Gura', gen:'EN Myth', color:'#60a5fa', motifs:['サメ','フード'], traits:['いたずら'], outfit:['サメパーカー'], keywords:['shark hoodie','trident'] },
{ key:'Takanashi Kiara', gen:'EN Myth', color:'#fb923c', motifs:['鳥','オレンジ'], traits:['情熱'], outfit:['アイドル衣装'], keywords:['phoenix','orange hair'] },
{ key:"Ninomae Ina'nis", gen:'EN Myth', color:'#a78bfa', motifs:['触手','本'], traits:['穏やか'], outfit:['修道服風'], keywords:['tentacle motif','violet'] }, <!-- ★ 修正済み:ダブルクォート -->
{ key:'Amelia Watson', gen:'EN Myth', color:'#fbbf24', motifs:['探偵','時計'], traits:['好奇心'], outfit:['探偵コート'], keywords:['detective','magnifying glass'] },
{ key:'Hakos Baelz', gen:'EN Council', color:'#ef4444', motifs:['ネズミ','カオス'], traits:['ハイテンション'], outfit:['赤系衣装'], keywords:['chaos','rat tail'] },
{ key:'IRyS', gen:'EN Project:Hope', color:'#ef5fff', motifs:['天使悪魔','クリスタル'], traits:['希望'], outfit:['黒×赤×紫'], keywords:['nephilim','crystal'] },
// ID (抜粋)
{ key:'Kobo Kanaeru', gen:'ID3', color:'#60a5fa', motifs:['雨','水'], traits:['やんちゃ'], outfit:['青系パーカー'], keywords:['rain theme','blue hair'] },
];
// ネガティブテンプレ
const NEGATIVE = 'nsfw, nude, lowres, low quality, worst quality, extra fingers, deformed hands, poorly drawn, watermark, logo, signature, text, blurry, jpeg artifacts, bad anatomy, out of frame';
// UI取得
const memberSearch = document.getElementById('memberSearch');
const memberList = document.getElementById('memberList');
const templateSelect = document.getElementById('templateSelect');
const stylePreset = document.getElementById('stylePreset');
const moodPreset = document.getElementById('moodPreset');
const aspectPreset = document.getElementById('aspectPreset');
const outfitInput = document.getElementById('outfitInput');
const bgInput = document.getElementById('bgInput');
const modelInput = document.getElementById('modelInput');
const extraInput = document.getElementById('extraInput');
const langToggle = document.getElementById('langToggle');
const nsfwSafe = document.getElementById('nsfwSafe');
const useNeg = document.getElementById('useNeg');
const addPose = document.getElementById('addPose');
const addCamera = document.getElementById('addCamera');
const generateBtn = document.getElementById('generateBtn');
const copyBtn = document.getElementById('copyBtn');
const saveBtn = document.getElementById('saveBtn');
const exportBtn = document.getElementById('exportBtn');
const importBtn = document.getElementById('importBtn');
const randomBtn = document.getElementById('randomBtn');
const resetBtn = document.getElementById('resetBtn');
const posOut = document.getElementById('posOut');
const negOut = document.getElementById('negOut');
const metaRes = document.getElementById('metaRes');
const metaCfg = document.getElementById('metaCfg');
const metaSampler = document.getElementById('metaSampler');
const history = document.getElementById('history');
const clearHist = document.getElementById('clearHist');
// 状態
let selectedMember = null;
// 初期描画
function renderMembers(filter=''){
const f = filter.toLowerCase().trim();
memberList.innerHTML = '';
MEMBERS.filter(m=>{
const s = [m.key, m.gen, ...(m.motifs||[]), ...(m.traits||[]), ...(m.keywords||[])].join(' ').toLowerCase();
return !f || s.includes(f);
}).forEach(m=>{
const li = document.createElement('li');
li.className = 'chip rounded-xl px-3 py-2 flex items-center gap-2 hover:bg-white/5 cursor-pointer';
li.innerHTML = `<span class="inline-block size-3 rounded-full" style="background:${m.color}"></span><span class="font-medium">${m.key}</span><span class="text-xs text-slate-400">(${m.gen})</span>`;
// クリック選択
li.addEventListener('click',()=>{ selectedMember = m; highlightSelection(li); });
// ★ キーボード対応
li.setAttribute('tabindex', '0');
li.setAttribute('role', 'button');
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedMember = m;
highlightSelection(li);
}
});
// 既選択のハイライト維持
if (selectedMember && selectedMember.key === m.key) li.classList.add('bg-white/10');
memberList.appendChild(li);
})
}
function highlightSelection(activeLi){
[...memberList.children].forEach(li=> li.classList.remove('bg-white/10'));
activeLi.classList.add('bg-white/10');
}
memberSearch.addEventListener('input', e=> renderMembers(e.target.value));
// アスペクト → 表示解像度
function applyAspect(){
const map = {
square: '1024x1024',
portrait: '768x1152',
landscape: '1152x768',
thumb: '1280x720'
};
metaRes.value = map[aspectPreset.value] || '1024x1024';
}
aspectPreset.addEventListener('change', applyAspect);
applyAspect();
// テンプレ文
function templateText(id, name){
const jp = {
portrait: `${name}の高品質なバストアップポートレート, 視線はこちら, きらめく瞳, 細密な髪, 肌の質感,`,
fullbody: `${name}の全身イラスト, ダイナミックなポーズ,`,
chibi: `${name}のデフォルメちびキャラ, 等身2~3,`,
vtuber: `${name}の配信サムネ風イラスト, サイドライト, 目を引くタイポの余白,`,
live2d: `${name}のLive2D立ち絵, 胸上〜腰上, レイヤー分けしやすいシンプル背景,`,
vrchat: `${name}のVRChatアバター風デザイン, 全身, セルルック,`,
manga: `${name}が登場するモノクロ漫画コマ, スクリーントーン,`,
poster: `${name}のキービジュアル, 迫力のある構図,`,
landscape: `背景美術の中に小さく${name}, 遠景, 雰囲気重視,`
};
const en = {
portrait: `high-quality bust portrait of ${name}, looking at viewer, sparkling eyes, detailed hair, skin texture,`,
fullbody: `full-body illustration of ${name}, dynamic pose,`,
chibi: `super-deformed chibi ${name}, 2~3 heads tall,`,
vtuber: `stream thumbnail style illustration of ${name}, side lighting, space for bold typography,`,
live2d: `Live2D standing illustration of ${name}, bust to waist-up, simple background for easy layer separation,`,
vrchat: `VRChat avatar style design of ${name}, full body, toon shading,`,
manga: `monochrome manga panel featuring ${name}, screen tones,`,
poster: `key visual poster of ${name}, impactful composition,`,
landscape: `cinematic background with small ${name} in scene, distant view, mood-focused,`
}
return (langToggle.checked? en : jp)[id] || '';
}
// スタイル・ムード
function styleText(id){
const jp = {
anime: 'アニメ塗り, 高発色, クリアライン,',
semiReal: 'セミリアル, 繊細なライティング, 細密質感,',
watercolor: '水彩風, 柔らかい発色, にじみ,',
celshade: 'セルシェーディング, シャープな影,',
painterly: '厚塗り, 筆致, 奥行き,',
threetoon: '3Dトゥーン, ノンフォトリアル,'
};
const en = {
anime: 'anime coloring, vivid, clean linework,',
semiReal: 'semi-realistic, delicate lighting, fine textures,',
watercolor: 'watercolor style, soft colors, bleeding,',
celshade: 'cel-shaded, sharp shadows,',
painterly: 'painterly, visible brush strokes, depth,',
threetoon: '3D toon, non-photorealistic,'
};
const key = id === '3dtoon' ? 'threetoon' : id;
return (langToggle.checked? en : jp)[key] || '';
}
function moodText(id){
const jp = {
soft: 'やわらかい環境光, 穏やかな表情,',
dramatic: 'ドラマチックライティング, リムライト, コントラスト強,',
studio: 'スタジオ照明, 均一な光, 背景シンプル,',
sunset: '夕焼けの光, ゴールデンアワー,',
night: '夜景ネオン, グロー, 反射,'
};
const en = {
soft: 'soft ambient light, gentle expression,',
dramatic: 'dramatic lighting, rim light, high contrast,',
studio: 'studio lighting, even illumination, simple background,',
sunset: 'sunset glow, golden hour,',
night: 'neon nightscape, glow, reflections,'
};
return (langToggle.checked? en : jp)[id] || '';
}
function poseText(){
const jp = ['ピースサイン','片手を胸に','ほほえみ','ダンスポーズ','跳躍'];
const en = ['peace sign','hand on chest','gentle smile','dance pose','jumping'];
const arr = langToggle.checked? en : jp;
return arr[Math.floor(Math.random()*arr.length)]
}
function cameraText(){
const jp = ['50mmレンズ相当','f1.8被写界深度','極小ノイズ','シャープ'];
const en = ['50mm lens equivalent','f1.8 shallow depth of field','very low noise','sharp'];
const arr = langToggle.checked? en : jp;
return arr.join(', ');
}
function buildPositive(){
if(!selectedMember){
alert('メンバーを選択してください');
return '';
}
const name = selectedMember.key;
const tp = templateText(templateSelect.value, name);
const st = styleText(stylePreset.value);
const md = moodText(moodPreset.value);
const ex = extraInput.value?.trim();
const pieces = [tp, st, md];
if(outfitInput.value.trim()) pieces.push(langToggle.checked? `outfit: ${outfitInput.value.trim()}` : `衣装: ${outfitInput.value.trim()}`);
if(bgInput.value.trim()) pieces.push(langToggle.checked? `background: ${bgInput.value.trim()}` : `背景: ${bgInput.value.trim()}`);
if(addPose.checked) pieces.push(langToggle.checked? `pose: ${poseText()}` : `ポーズ: ${poseText()}`);
if(addCamera.checked) pieces.push(cameraText());
// メンバー特徴
const motif = (selectedMember.motifs||[]).join(', ');
const trait = (selectedMember.traits||[]).join(', ');
const kw = (selectedMember.keywords||[]).join(', ');
const profJP = `特徴: ${motif}, 性格: ${trait}, キーワード: ${kw}`;
const profEN = `motifs: ${motif}, traits: ${trait}, keywords: ${kw}`;
pieces.push(langToggle.checked? profEN : profJP);
if(ex) pieces.push(ex);
// モデル/LoRA
if(modelInput.value.trim()) pieces.push(`[${modelInput.value.trim()}]`);
// NSFW安全
if(nsfwSafe.checked){
pieces.push(langToggle.checked? 'sfw, wholesome' : '全年齢, 健全');
}
return pieces.filter(Boolean).join(' ');
}
function buildNegative(){
return useNeg.checked ? NEGATIVE : '';
}
function generate(){
const pos = buildPositive();
if (!pos) return;
posOut.value = pos;
negOut.value = buildNegative();
}
function copyAll(){
const txt = `Positive:\n${posOut.value}\n\nNegative:\n${negOut.value}\n\nMeta:\nres=${metaRes.value}, ${metaCfg.value}, sampler=${metaSampler.value}`;
navigator.clipboard.writeText(txt).then(()=>{ toast('コピーしました'); });
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-white/10 border border-white/10 shadow-glass';
document.body.appendChild(t);
setTimeout(()=> t.remove(), 1600);
}
function saveHistory(){
const item = {
time: new Date().toISOString(),
member: selectedMember?.key || '(未選択)',
template: templateSelect.value,
style: stylePreset.value,
mood: moodPreset.value,
aspect: aspectPreset.value,
pos: posOut.value,
neg: negOut.value,
meta: { res: metaRes.value, cfg: metaCfg.value, sampler: metaSampler.value }
};
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
arr.unshift(item);
// 保存上限:最新から最大100件(文字列長で切るとJSONが壊れるため件数で制御)
const MAX_ITEMS = 100;
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr.slice(0, MAX_ITEMS)));
renderHistory();
toast('履歴に保存しました');
}
function renderHistory(){
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
history.innerHTML = '';
arr.forEach((it, idx)=>{
const card = document.createElement('div');
card.className = 'rounded-xl p-3 border border-white/10 bg-black/30';
card.innerHTML = `
<div class='flex items-center gap-2 mb-2'>
<span class='text-slate-300 text-sm'>${new Date(it.time).toLocaleString()}</span>
<span class='ms-auto text-xs chip rounded-lg px-2 py-0.5'>${it.member}</span>
</div>
<div class='text-xs text-slate-400 mb-2'>${it.template} / ${it.style} / ${it.mood} / ${it.aspect}</div>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Positive</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.pos)}</pre>
</details>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Negative</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.neg)}</pre>
</details>
<div class='flex gap-2'>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='load' data-idx='${idx}'>読み込む</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='copy' data-idx='${idx}'>コピー</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='del' data-idx='${idx}'>削除</button>
</div>
`;
card.addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const { act, idx } = btn.dataset;
const list = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
if(act==='del'){
list.splice(idx,1);
localStorage.setItem('holo_prompt_hist', JSON.stringify(list));
renderHistory();
}else if(act==='copy'){
navigator.clipboard.writeText(`Positive:\n${list[idx].pos}\n\nNegative:\n${list[idx].neg}`);
toast('コピーしました');
}else if(act==='load'){
loadHistoryItem(list[idx]);
}
})
history.appendChild(card);
})
}
function loadHistoryItem(it){
selectedMember = MEMBERS.find(m=> m.key === it.member) || null;
templateSelect.value = it.template;
stylePreset.value = it.style;
moodPreset.value = it.mood;
aspectPreset.value = it.aspect; applyAspect();
posOut.value = it.pos; negOut.value = it.neg;
toast('履歴を読み込みました');
renderMembers(memberSearch.value);
}
clearHist.addEventListener('click', ()=>{
localStorage.removeItem('holo_prompt_hist');
renderHistory();
toast('履歴を削除しました');
});
// JSON入出力
exportBtn.addEventListener('click', ()=>{
const data = localStorage.getItem('holo_prompt_hist')||'[]';
const blob = new Blob([data], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'holo_prompt_history.json';
a.click();
});
importBtn.addEventListener('click', ()=>{
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'application/json';
inp.onchange = () => {
const file = inp.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = e => {
try{
const arr = JSON.parse(e.target.result);
if(Array.isArray(arr)){
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr));
renderHistory();
toast('JSONを読み込みました');
} else { alert('不正なJSONです'); }
}catch(err){ alert('読み込み失敗: '+err.message); }
};
reader.readAsText(file);
};
inp.click();
});
// ランダム&リセット
randomBtn.addEventListener('click', ()=>{
selectedMember = MEMBERS[Math.floor(Math.random()*MEMBERS.length)];
renderMembers(memberSearch.value);
generate();
toast('ランダム選択しました');
});
resetBtn.addEventListener('click', ()=>{
memberSearch.value=''; selectedMember=null; renderMembers('');
templateSelect.value='portrait'; stylePreset.value='anime'; moodPreset.value='soft'; aspectPreset.value='square'; applyAspect();
outfitInput.value=''; bgInput.value=''; modelInput.value=''; extraInput.value='';
posOut.value=''; negOut.value='';
toast('初期化しました');
});
// 生成/コピー/保存
generateBtn.addEventListener('click', generate);
copyBtn.addEventListener('click', copyAll);
saveBtn.addEventListener('click', saveHistory);
// 言語切替時に再生成
langToggle.addEventListener('change', ()=>{ if(posOut.value) generate(); });
// HTMLエスケープ
function escapeHtml(str=''){
return str.replace(/[&<>"]/g, s=> ({'&':'&','<':'<','>':'>','"':'"'}[s]));
}
// 初期描画
renderMembers('');
renderHistory();
</script>
</body>
</html>