Spaces:
Sleeping
Sleeping
| import Anthropic from '@anthropic-ai/sdk'; | |
| import OpenAI from 'openai'; | |
| import { ImageElement } from './html-image.analyzer'; | |
| /** | |
| * 画像生成指示の詳細情報 | |
| */ | |
| export interface ImagePromptInstruction { | |
| /** 画像要素のID */ | |
| imageId: string; | |
| /** メインプロンプト */ | |
| mainPrompt: string; | |
| /** スタイル指示 */ | |
| styleInstructions: string; | |
| /** 技術的制約 */ | |
| technicalConstraints: string; | |
| /** 完全なFirefly用プロンプト */ | |
| fireflyPrompt: string; | |
| /** 推定生成時間(秒) */ | |
| estimatedGenerationTime: number; | |
| } | |
| /** | |
| * コンテンツコンテキスト情報 | |
| */ | |
| export interface ContentContext { | |
| /** 企業名 */ | |
| companyName?: string; | |
| /** サービス/商品名 */ | |
| serviceName?: string; | |
| /** 業界・業種 */ | |
| industry?: string; | |
| /** ターゲット層 */ | |
| targetAudience?: string; | |
| /** ブランドトーン */ | |
| brandTone?: string; | |
| /** 全体のテーマ */ | |
| overallTheme?: string; | |
| } | |
| /** | |
| * AI画像指示生成サービス | |
| */ | |
| export class ImagePromptGenerator { | |
| private openai?: OpenAI; | |
| private anthropic?: Anthropic; | |
| constructor() { | |
| // OpenAI クライアント初期化 | |
| const openaiKey = process.env.OPENAI_TEMPLATE_AI_DEV; | |
| if (openaiKey) { | |
| this.openai = new OpenAI({ | |
| apiKey: openaiKey, | |
| }); | |
| } | |
| // Claude クライアント初期化 | |
| const claudeKey = process.env.CLAUDE_TEMPLATE_AI_DEV; | |
| if (claudeKey) { | |
| this.anthropic = new Anthropic({ | |
| apiKey: claudeKey, | |
| }); | |
| } | |
| } | |
| /** | |
| * 画像要素配列から画像生成指示を一括生成 | |
| */ | |
| async generateImageInstructions( | |
| imageElements: ImageElement[], | |
| contentContext: ContentContext = {}, | |
| provider: 'openai' | 'claude' = 'openai', | |
| ): Promise<ImagePromptInstruction[]> { | |
| // Starting batch generation | |
| const instructions: ImagePromptInstruction[] = []; | |
| // セクションごとに画像をグループ化 | |
| const sectionGroups = new Map<string, { element: ImageElement; index: number }[]>(); | |
| imageElements.forEach((element, globalIndex) => { | |
| const sectionKey = `${element.context.sectionType}-${element.context.sectionTitle}`; | |
| if (!sectionGroups.has(sectionKey)) { | |
| sectionGroups.set(sectionKey, []); | |
| } | |
| sectionGroups.get(sectionKey)!.push({ element, index: globalIndex }); | |
| }); | |
| // 並列処理で画像指示を生成 | |
| const promises = imageElements.map(async (element, globalIndex) => { | |
| try { | |
| // セクション内でのインデックスを取得 | |
| const sectionKey = `${element.context.sectionType}-${element.context.sectionTitle}`; | |
| const sectionImages = sectionGroups.get(sectionKey) || []; | |
| const sectionIndex = sectionImages.findIndex((img) => img.element.id === element.id); | |
| const instruction = await this.generateSingleImageInstruction(element, contentContext, provider, sectionIndex, sectionImages.length); | |
| return instruction; | |
| } catch (error) { | |
| console.error(`[ImagePromptGenerator] Failed to generate instruction for image ${element.id}:`, error); | |
| // フォールバック指示を生成 | |
| return this.generateFallbackInstruction(element, globalIndex); | |
| } | |
| }); | |
| const results = await Promise.allSettled(promises); | |
| results.forEach((result, index) => { | |
| if (result.status === 'fulfilled') { | |
| instructions.push(result.value); | |
| } else { | |
| console.error(`[ImagePromptGenerator] Promise failed for image ${imageElements[index].id}:`, result.reason); | |
| // フォールバック指示を追加 | |
| instructions.push(this.generateFallbackInstruction(imageElements[index], index)); | |
| } | |
| }); | |
| // Generated instructions | |
| return instructions; | |
| } | |
| /** | |
| * 単一画像要素の生成指示を作成 | |
| */ | |
| private async generateSingleImageInstruction( | |
| element: ImageElement, | |
| contentContext: ContentContext, | |
| provider: 'openai' | 'claude', | |
| sectionIndex: number = 0, | |
| totalInSection: number = 1, | |
| ): Promise<ImagePromptInstruction> { | |
| try { | |
| const prompt = this.buildAnalysisPrompt(element, contentContext, sectionIndex, totalInSection); | |
| let aiResponse: string; | |
| if (provider === 'claude' && this.anthropic) { | |
| aiResponse = await this.generateWithClaude(prompt); | |
| } else if (provider === 'openai' && this.openai) { | |
| aiResponse = await this.generateWithOpenAI(prompt); | |
| } else { | |
| throw new Error(`Provider ${provider} is not available`); | |
| } | |
| return this.parseAIResponse(element.id, aiResponse, sectionIndex); | |
| } catch (error) { | |
| console.error(`[ImagePromptGenerator] Failed to generate instruction for image ${element.id}:`, error); | |
| // フォールバック: デフォルトのインストラクションを返す | |
| return { | |
| imageId: element.id, | |
| mainPrompt: `Professional image for ${element.altText || 'business content'}`, | |
| styleInstructions: 'Modern, clean, professional design with appropriate color scheme', | |
| technicalConstraints: 'Image will be center-cropped to 4:3 aspect ratio (1024x768px), place important elements in central horizontal band', | |
| fireflyPrompt: `Professional business image for section ${sectionIndex + 1}`, | |
| estimatedGenerationTime: 30, | |
| }; | |
| } | |
| } | |
| /** | |
| * Claude APIを使用して画像指示を生成 | |
| */ | |
| private async generateWithClaude(prompt: string): Promise<string> { | |
| if (!this.anthropic) { | |
| throw new Error('Claude client not initialized'); | |
| } | |
| const response = await this.anthropic.messages.create({ | |
| model: 'claude-3-5-sonnet-20241022', | |
| max_tokens: 1000, | |
| messages: [ | |
| { | |
| role: 'user', | |
| content: prompt, | |
| }, | |
| ], | |
| }); | |
| const content = response.content[0]; | |
| if (content.type === 'text') { | |
| return content.text; | |
| } | |
| throw new Error('Unexpected response format from Claude'); | |
| } | |
| /** | |
| * OpenAI APIを使用して画像指示を生成 | |
| */ | |
| private async generateWithOpenAI(prompt: string): Promise<string> { | |
| if (!this.openai) { | |
| throw new Error('OpenAI client not initialized'); | |
| } | |
| const response = await this.openai.chat.completions.create({ | |
| model: 'gpt-4o', | |
| max_tokens: 1000, | |
| messages: [ | |
| { | |
| role: 'user', | |
| content: prompt, | |
| }, | |
| ], | |
| }); | |
| const content = response.choices[0].message.content; | |
| if (!content) { | |
| throw new Error('Empty response from OpenAI'); | |
| } | |
| return content; | |
| } | |
| /** | |
| * AI分析用プロンプトを構築 | |
| */ | |
| private buildAnalysisPrompt(element: ImageElement, contentContext: ContentContext, sectionIndex: number = 0, totalInSection: number = 1): string { | |
| // セクション内での画像の位置に基づく追加指示 | |
| let positionContext = ''; | |
| if (totalInSection > 1) { | |
| if (sectionIndex === 0) { | |
| positionContext = '導入・概要を示す画像として、セクションの最初に配置'; | |
| } else if (sectionIndex === totalInSection - 1) { | |
| positionContext = 'まとめ・結論を示す画像として、セクションの最後に配置'; | |
| } else { | |
| positionContext = `詳細説明用の画像として、セクション内の${sectionIndex + 1}番目に配置`; | |
| } | |
| } | |
| // 画像のバリエーション指示 | |
| const variationInstructions = [ | |
| '広角で全体像を捉えた構図', | |
| '特定の要素にフォーカスした詳細な構図', | |
| 'バランスの取れた中距離からの構図', | |
| 'ダイナミックな角度からの印象的な構図', | |
| '俯瞰的な視点からの包括的な構図', | |
| ]; | |
| const selectedVariation = variationInstructions[sectionIndex % variationInstructions.length]; | |
| // ヒーロー画像の場合の特別な構成指示 | |
| const heroCompositionRule = | |
| element.context.imageRole === 'main' || element.context.sectionType === 'hero' | |
| ? ` | |
| # ヒーロー画像の構成ルール(必須) | |
| - 画像上部の空白領域は高さの10%未満に制限 | |
| - メインビジュアル要素(人物・オブジェクト・グラフィック)を画像上部から配置 | |
| - ヘッダー直下に密度の高い構成で配置 | |
| - 無駄な上部余白を避け、インパクトのある配置` | |
| : ''; | |
| return `あなたはプロの画像ディレクターです。以下の情報を基に、Adobe Fireflyで生成する画像の詳細な指示を日本語で作成してください。 | |
| # 画像要素の情報 | |
| - 画像の役割: ${element.context.imageRole} | |
| - セクションタイプ: ${element.context.sectionType} | |
| - セクションタイトル: ${element.context.sectionTitle} | |
| - 画像サイズ: ${element.width}x${element.height}px(注:生成後に上下中央で4:3アスペクト比(1024x768px)にトリミングされます。重要な要素は中央の水平帯に配置してください) | |
| - Alt属性: ${element.altText} | |
| - 周辺コンテキスト: ${element.context.surroundingText} | |
| - セクション内の位置: ${positionContext || '単独の画像'} | |
| - 推奨される構図: ${selectedVariation}${heroCompositionRule} | |
| # コンテンツコンテキスト | |
| - 企業名: ${contentContext.companyName || '未指定'} | |
| - サービス名: ${contentContext.serviceName || '未指定'} | |
| - 業界: ${contentContext.industry || '未指定'} | |
| - ターゲット層: ${contentContext.targetAudience || '未指定'} | |
| - ブランドトーン: ${contentContext.brandTone || '未指定'} | |
| # 出力形式 | |
| 以下のJSON形式で回答してください: | |
| \`\`\`json | |
| { | |
| "mainPrompt": "画像の主要な内容と構図を具体的に記述", | |
| "styleInstructions": "視覚的スタイル、色調、雰囲気の指示", | |
| "technicalConstraints": "技術的制約やサイズ比率の考慮事項", | |
| "estimatedTime": 30 | |
| } | |
| \`\`\` | |
| # 重要な制約 | |
| 1. 日本語コンテンツに適した画像を想定 | |
| 2. プロフェッショナルなビジネス向け画像 | |
| 3. 著作権に配慮したオリジナル要素 | |
| 4. 人物を含む場合は写真調でリアルな表現とし、特定個人を避ける。人物が登場するシーンでは、イラスト調ではなくプロフェッショナルな写真スタイルで表現 | |
| 5. ブランドロゴや具体的な商品名は避ける | |
| 6. ${element.context.imageRole === 'icon' ? 'シンプルで認識しやすいアイコン的デザイン' : ''} | |
| 7. ${element.context.imageRole === 'main' ? 'インパクトがありメインビジュアルとして機能する。上部余白を最小化し、視覚要素を画像上部から開始' : ''} | |
| 8. ${element.context.imageRole === 'illustration' ? 'コンテンツを補完する説明的なビジュアル。人物が含まれる場合でも写真調で表現' : ''} | |
| 9. 同じセクション内の他の画像と差別化された独自の視点 | |
| 10. 【重要】画像は上下中央で4:3比率にトリミングされるため、キー要素は中央の水平帯に配置し、上下端は避けること | |
| 画像の目的とコンテキストに最適化された、具体的で実行可能な指示を作成してください。必ず「${selectedVariation}」を活用してください。`; | |
| } | |
| /** | |
| * AI応答を解析してImagePromptInstructionに変換 | |
| */ | |
| private parseAIResponse(imageId: string, response: string, sectionIndex: number = 0): ImagePromptInstruction { | |
| try { | |
| // JSON部分を抽出 - コードブロックまたは生のJSONを処理 | |
| let jsonContent: string; | |
| // まずコードブロック形式を試す | |
| const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/); | |
| if (jsonMatch) { | |
| jsonContent = jsonMatch[1]; | |
| } else { | |
| // コードブロックがない場合は、JSONオブジェクトを直接探す | |
| const jsonObjectMatch = response.match(/\{[\s\S]*"mainPrompt"[\s\S]*\}/); | |
| if (jsonObjectMatch) { | |
| jsonContent = jsonObjectMatch[0]; | |
| } else { | |
| throw new Error('JSON format not found in response'); | |
| } | |
| } | |
| // JSON文字列のクリーンアップ処理 | |
| // 1. フィールド値内の改行を\\nに置換 | |
| jsonContent = jsonContent.replace(/"([^"]*)":\s*"([^"]*)"/g, (match, key, value) => { | |
| // 値内の改行文字を\\nに置換 | |
| const cleanedValue = value.replace(/\r\n/g, '\\n').replace(/\r/g, '\\n').replace(/\n/g, '\\n').replace(/\t/g, '\\t'); | |
| return `"${key}": "${cleanedValue}"`; | |
| }); | |
| // 2. 末尾の余分な文字を除去(例:余分な「」」など) | |
| jsonContent = jsonContent.replace(/(["\]}])[^,\s\]}]*$/gm, '$1'); | |
| // 3. 不完全な文字列フィールドを修正 | |
| jsonContent = jsonContent.replace(/"([^"]*)":\s*"([^"]*),\s*$/gm, (match, key, value) => { | |
| return `"${key}": "${value}",`; | |
| }); | |
| // 4. 最後のフィールドが閉じられていない場合の処理 | |
| jsonContent = jsonContent.replace(/"([^"]*)":\s*"([^"]*)\s*$/gm, (match, key, value) => { | |
| return `"${key}": "${value}"`; | |
| }); | |
| // 5. 不正な改行や制御文字を含む場合の追加クリーンアップ | |
| const lines = jsonContent.split('\n'); | |
| const cleanedLines = lines.map((line) => { | |
| // フィールド値が途中で切れている場合の処理 | |
| if (line.includes('": "') && !line.trim().endsWith('",') && !line.trim().endsWith('"')) { | |
| return line + '"'; | |
| } | |
| return line; | |
| }); | |
| jsonContent = cleanedLines.join('\n'); | |
| const parsedResponse = JSON.parse(jsonContent); | |
| const fireflyPrompt = this.buildFireflyPrompt( | |
| parsedResponse.mainPrompt, | |
| parsedResponse.styleInstructions, | |
| parsedResponse.technicalConstraints, | |
| sectionIndex, | |
| imageId, | |
| ); | |
| return { | |
| imageId, | |
| mainPrompt: parsedResponse.mainPrompt || '', | |
| styleInstructions: parsedResponse.styleInstructions || '', | |
| technicalConstraints: parsedResponse.technicalConstraints || '', | |
| fireflyPrompt, | |
| estimatedGenerationTime: parsedResponse.estimatedTime || 30, | |
| }; | |
| } catch (error) { | |
| console.error(`[ImagePromptGenerator] Failed to parse AI response for ${imageId}:`, error); | |
| console.error('Raw response:', response); | |
| // フォールバック: デフォルト値を返す | |
| console.warn(`[ImagePromptGenerator] Using fallback prompt for ${imageId}`); | |
| return { | |
| imageId, | |
| mainPrompt: 'Professional business image', | |
| styleInstructions: 'Modern, clean design with blue tones', | |
| technicalConstraints: 'Size: 800x400px, landscape orientation', | |
| fireflyPrompt: `Professional business image for section ${sectionIndex + 1}`, | |
| estimatedGenerationTime: 30, | |
| }; | |
| } | |
| } | |
| /** | |
| * Firefly用の最終プロンプトを構築 | |
| */ | |
| private buildFireflyPrompt( | |
| mainPrompt: string, | |
| styleInstructions: string, | |
| technicalConstraints: string, | |
| sectionIndex: number = 0, | |
| imageId: string = '', | |
| ): string { | |
| // 画像の位置に基づくバリエーション | |
| const viewVariations = [ | |
| 'wide angle perspective', | |
| 'detailed close-up view', | |
| 'balanced mid-range shot', | |
| 'dynamic diagonal composition', | |
| 'elevated overview angle', | |
| ]; | |
| const viewStyle = viewVariations[sectionIndex % viewVariations.length]; | |
| // ユニークな要素を追加 | |
| const uniqueElement = imageId ? `unique visual element ${imageId.slice(-4)}` : ''; | |
| const basePrompt = `${mainPrompt}`; | |
| const styleSection = styleInstructions ? ` Style: ${styleInstructions}, ${viewStyle}` : ` Style: ${viewStyle}`; | |
| const constraintsSection = technicalConstraints ? ` Technical requirements: ${technicalConstraints}` : ''; | |
| // ヒーロー画像の場合は上部余白制限を追加 | |
| const heroConstraint = | |
| imageId.includes('hero') || imageId.includes('main') | |
| ? ', minimal top margin, dense composition from top edge, visual elements starting immediately' | |
| : ''; | |
| const fireflyConstraints = ` Professional business photography, high quality, 4K resolution, no identifiable individuals, stock photo aesthetic, clean and modern composition${heroConstraint}, ${uniqueElement}.`; | |
| return `${basePrompt}.${styleSection}.${constraintsSection}.${fireflyConstraints}`; | |
| } | |
| /** | |
| * フォールバック指示を生成(AI生成に失敗した場合) | |
| */ | |
| private generateFallbackInstruction(element: ImageElement, index: number = 0): ImagePromptInstruction { | |
| const roleInstructions = { | |
| main: 'Professional business hero image with minimal top padding, modern corporate design, high impact visual starting from top edge', | |
| icon: 'Simple minimalist icon, clean design, professional style', | |
| illustration: 'Business concept illustration, modern corporate style', | |
| decoration: 'Subtle decorative element, professional background texture', | |
| }; | |
| const sectionInstructions = { | |
| problem: 'Business challenge visualization, problem representation', | |
| solution: 'Solution concept, positive business outcome', | |
| features: 'Feature showcase, benefits visualization', | |
| service: 'Service representation, professional offering', | |
| flow: 'Process illustration, step-by-step visualization', | |
| generic: 'Professional business concept, modern corporate design', | |
| }; | |
| const mainPrompt = `${roleInstructions[element.context.imageRole as keyof typeof roleInstructions] || roleInstructions.illustration} representing ${sectionInstructions[element.context.sectionType as keyof typeof sectionInstructions] || sectionInstructions.generic}`; | |
| const fireflyPrompt = this.buildFireflyPrompt( | |
| mainPrompt, | |
| 'Professional corporate style, modern design', | |
| `Aspect ratio optimized for ${element.width}x${element.height} display`, | |
| index, | |
| element.id, | |
| ); | |
| return { | |
| imageId: element.id, | |
| mainPrompt, | |
| styleInstructions: 'Professional corporate style, modern design', | |
| technicalConstraints: `Aspect ratio optimized for ${element.width}x${element.height} display`, | |
| fireflyPrompt, | |
| estimatedGenerationTime: 30, | |
| }; | |
| } | |
| /** | |
| * 利用可能なプロバイダーをチェック | |
| */ | |
| getAvailableProviders(): ('openai' | 'claude')[] { | |
| const providers: ('openai' | 'claude')[] = []; | |
| if (this.openai) providers.push('openai'); | |
| if (this.anthropic) providers.push('claude'); | |
| return providers; | |
| } | |
| } | |