/* tslint:disable */ /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { GoogleGenAI, Modality, Part, Type } from '@google/genai'; import * as THREE from 'three'; // --- Types --- declare global { interface Window { lucide: { createIcons: () => void; }; } } type ReferenceImage = { id: string; name: string; data: string; mimeType: string; role: 'base' | 'reference'; prompt: string; isFace: boolean; }; type HistoryImage = { id: number; blob: Blob; timestamp: number; }; type HistoryItem = { id: number; url: string; }; type ModalOption = { name: string; description: string; value?: number; }; type ModalOptionsData = { [key: string]: { title: string; options: ModalOption[]; }; }; // --- Data for Modals --- const MODAL_OPTIONS: ModalOptionsData = { artisticStyle: { title: 'Select an Artistic Style', options: [ { name: "Photorealistic", description: "Aims for a high level of realism, resembling a photograph." }, { name: "Anime", description: "Characterized by vibrant colors, exaggerated features, and a distinct Japanese animation style." }, { name: "Oil Painting", description: "Mimics the texture, brush strokes, and rich colors of a traditional oil painting." }, { name: "Watercolor", description: "Features soft, translucent colors with visible water-like textures and bleeding effects." }, { name: "Concept Art", description: "Focuses on conveying an idea or mood for use in films, video games, or animation. Often has a painterly, illustrative feel." }, { name: "Steampunk", description: "A retrofuturistic style incorporating technology and aesthetics inspired by 19th-century industrial steam-powered machinery." }, { name: "Cyberpunk", description: "A futuristic style characterized by high-tech, neon-lit, dystopian urban environments." }, { name: "Fantasy", description: "Depicts magical or mythological elements, creatures, and otherworldly settings." }, { name: "Minimalist", description: "Uses a limited number of elements, emphasizing simplicity, clean lines, and negative space." }, { name: "Abstract", description: "Uses shapes, forms, colors, and textures without attempting to represent external reality." }, { name: "Impressionism", description: "Characterized by small, thin, yet visible brush strokes, open composition, and an emphasis on the accurate depiction of light." }, { name: "Pop Art", description: "Inspired by popular and commercial culture, often using bold colors and imagery from advertising or comics." }, { name: "Surrealism", description: "Features dream-like, bizarre, and illogical scenes to unlock the power of the unconscious mind." }, { name: "Art Deco", description: "A style of visual arts from the 1920s characterized by rich colors, bold geometric shapes, and lavish ornamentation." }, { name: "Gothic", description: "Characterized by dark, mysterious, and moody aesthetics with elements of horror and romance." }, { name: "Pixel Art", description: "A form of digital art, created through the use of software, where images are edited on the pixel level." }, { name: "Vaporwave", description: "An aesthetic characterized by a nostalgic engagement with retro computer graphics, 80s/90s culture, and glitch art." }, { name: "Low Poly", description: "A 3D computer graphics style that uses a small number of polygons to create a faceted, geometric appearance." }, { name: "Art Nouveau", description: "Characterized by its use of a long, sinuous, organic line and was used in architecture, interior design, jewelry, and illustration." }, { name: "Baroque", description: "Characterized by grandeur, sensuous richness, drama, vitality, movement, tension, and emotional exuberance." }, { name: "Bauhaus", description: "A German art school style known for its unique approach to design, which combines crafts and the fine arts." }, ], }, shotType: { title: 'Select a Shot Type', options: [ { name: "Detail Shot", description: "A macro-style shot, cropped extremely tight on a specific detail. For a face, this means the frame is filled with just the eyes and nose region, showing skin texture." }, { name: "Extreme Close-up", description: "The camera frames the subject's face from the bottom of the chin to the top of the forehead, capturing fine details of expressions." }, { name: "Close-up", description: "Tightly frames a person, showing their head and shoulders, to emphasize facial expression." }, { name: "Medium Close-up", description: "Frames a subject from the chest up, balancing facial detail with some context of the body." }, { name: "Medium Shot", description: "Shows the subject from the waist up, allowing for hand gestures and more body language to be visible." }, { name: "Cowboy Shot", description: "A variation of the medium shot, framing the subject from mid-thighs up, originally used in Westerns to show a character's gun holster." }, { name: "Full Shot", description: "Shows the entire subject from head to toe, with their body filling most of the frame." }, { name: "Long Shot", description: "Shows the full subject from a distance, with more of the background and setting visible. Also known as a Wide Shot." }, { name: "Wide Shot", description: "Shows the subject within their surrounding environment. The emphasis is on the setting and the character's relationship to it." }, { name: "Extreme Wide Shot", description: "A shot taken from a very long distance, used to show the setting on a grand scale and make the subject appear small." }, { name: "Establishing Shot", description: "Usually the first shot of a scene, designed to show the audience where the action is taking place." }, { name: "Macro Shot", description: "An extreme close-up shot of a very small object, magnifying it to show fine details invisible to the naked eye." }, { name: "Two-shot", description: "A shot that frames two characters, often used to show their interaction." }, { name: "Group Shot", description: "A shot that includes three or more characters in the frame." }, { name: "Insert Shot", description: "A close-up of a detail or object that is inserted into the main action to provide important information." }, { name: "Crane Shot", description: "A shot taken from a camera mounted on a crane, allowing for dynamic, high-angle, and sweeping movements." }, { name: "Drone Shot", description: "An aerial shot taken from a drone, offering a wide, high-altitude perspective of the scene." }, ], }, shotAngle: { title: 'Select a Shot Angle', options: [ { name: "Eye-Level", description: "The camera is placed at the subject's eye level, creating a neutral and relatable perspective." }, { name: "Low Angle", description: "The camera is placed below the subject's eye level, looking up at them. This can make the subject appear powerful, heroic, or intimidating." }, { name: "High Angle", description: "The camera is placed above the subject's eye level, looking down at them. This can make the subject appear vulnerable, small, or insignificant." }, { name: "Worm's-Eye View", description: "An extreme low angle shot taken from the ground, looking straight up. It dramatically distorts the subject, making them look immense and powerful." }, { name: "Bird's-Eye View", description: "An extreme high angle shot, as if from the perspective of a bird, directly overhead. It provides an overview of the scene and can create a sense of detachment." }, { name: "Dutch Angle", description: "The camera is tilted on its side, creating a skewed horizon line. It's used to convey disorientation, unease, or tension." }, { name: "Over-the-Shoulder", description: "A shot taken from behind one character, looking over their shoulder at another character. It's common in conversations to show perspective." }, { name: "Point-of-View (POV)", description: "The camera shows what the character is seeing, as if through their own eyes." }, { name: "Ground-Level", description: "The camera is placed on the ground, creating a low perspective that can emphasize a character's connection to the earth or show details on the ground." }, { name: "Knee-Level", description: "The camera is positioned at the height of a subject's knees, offering an unusual but engaging viewpoint." }, { name: "Hip-Level", description: "The camera is placed at the subject's hip, often used for stylish or action-oriented shots like the cowboy shot." }, { name: "Shoulder-Level", description: "A common and naturalistic camera height, slightly lower than eye-level, often used in conversational scenes." }, { name: "Oblique Angle", description: "Similar to a Dutch Angle, this involves tilting the camera to create a sense of imbalance or dynamism." }, ], }, lightingStyle: { title: 'Select a Lighting Style', options: [ { name: "Natural Light", description: "Use light from the sun as the primary source to create a realistic and often soft, diffused look. Avoid artificial light sources." }, { name: "Studio Light", description: "Use a controlled, multi-point artificial lighting setup (like key, fill, and backlights) to precisely shape the light and shadow on the subject." }, { name: "Dramatic Light", description: "Employs high-contrast lighting (low-key) to create bold shadows and a sense of drama or tension." }, { name: "Cinematic Light", description: "A broad term for lighting that creates a moody, atmospheric, and film-like quality, often using color and motivated light sources." }, { name: "Chiaroscuro", description: "An artistic technique using strong contrasts between light and dark, usually bold contrasts affecting a whole composition." }, { name: "Rim Light", description: "A light source placed behind the subject to create a bright outline around them, separating them from the background." }, { name: "Golden Hour", description: "The period shortly after sunrise or before sunset, known for its warm, soft, and directional light." }, { name: "Blue Hour", description: "The period just before sunrise or after sunset when the light is diffused and evenly blue." }, { name: "Film Noir", description: "A classic Hollywood style characterized by low-key, black-and-white visuals with stark light/dark contrasts and deep shadows." }, { name: "Volumetric Light", description: "Makes light beams visible, like sun rays shining through a window or light shafts in a foggy forest." }, { name: "Backlight", description: "The main light source is behind the subject, which can create a silhouette or a glowing halo effect." }, { name: "Hard Light", description: "Creates sharp, well-defined shadows and high contrast. Often produced by a small, direct light source like the midday sun." }, { name: "Soft Light", description: "Creates soft, diffused shadows with smooth transitions. Produced by a large, indirect light source like an overcast sky or a softbox." }, { name: "Neon Lighting", description: "Utilizes the bright, vibrant, and colorful glow of neon signs, common in cyberpunk and nighttime cityscapes." }, { name: "Rembrandt Lighting", description: "A studio lighting technique characterized by a small, inverted triangle of light on the subject's cheek that is less illuminated." }, { name: "Split Lighting", description: "A lighting setup that splits the subject's face into two halves, one lit and one in shadow, creating a dramatic effect." }, ], }, lensType: { title: 'Select a Lens Type', options: [ { name: "35mm", description: "A versatile, moderately wide-angle lens that provides a field of view similar to the human eye, great for street photography and environmental portraits." }, { name: "50mm", description: "Often called a 'nifty fifty', this standard prime lens offers a natural perspective with minimal distortion, excellent for portraits." }, { name: "85mm", description: "A classic portrait lens, this short telephoto lens beautifully compresses the background and creates a pleasing bokeh (background blur)." }, { name: "Wide-Angle Lens", description: "Captures a broad field of view, ideal for landscapes, architecture, and establishing shots. Can cause some distortion at the edges." }, { name: "Telephoto Lens", description: "Allows you to capture subjects from a great distance, compressing the perspective and making distant objects appear closer." }, { name: "Macro Lens", description: "Designed for extreme close-up photography, allowing for 1:1 magnification to capture intricate details of small subjects." }, { name: "Fisheye Lens", description: "An ultra-wide-angle lens that produces strong visual distortion, creating a wide, hemispherical image." }, { name: "Anamorphic Lens", description: "A cinematic lens that compresses the image horizontally, creating a widescreen aspect ratio, distinctive lens flares, and oval bokeh." }, { name: "Tilt-Shift Lens", description: "Allows for control over perspective and plane of focus, often used in architectural photography to correct converging lines or to create a miniature 'diorama' effect." }, { name: "Prime Lens", description: "A lens with a fixed focal length (e.g., 50mm). They are typically sharper and have a wider maximum aperture than zoom lenses." }, { name: "Zoom Lens", description: "A lens with a variable focal length, allowing you to change the field of view without changing your position." }, ], }, timeAndSetting: { title: 'Select Time & Setting', options: [ { name: "Daytime", description: "Set during the day, with the sun as the primary light source." }, { name: "Night", description: "Set during the night, with sources like the moon, stars, or artificial lights." }, { name: "Twilight", description: "The period between sunset and night, or before sunrise, with soft, ambient light." }, { name: "Golden Hour", description: "The period shortly after sunrise or before sunset with warm, soft light." }, { name: "Blue Hour", description: "The period just before sunrise or after sunset with cool, blue-toned light." }, { name: "Sunrise", description: "Depicts the moment the sun appears on the horizon in the morning." }, { name: "Sunset", description: "Depicts the moment the sun disappears on the horizon in the evening." }, { name: "Indoors", description: "The scene takes place inside a building or enclosed space." }, { name: "Outdoors", description: "The scene takes place outside in a natural or urban environment." }, { name: "Underwater", description: "The scene is set beneath the surface of water." }, { name: "Outer Space", description: "The setting is in the cosmos, among stars, planets, and galaxies." }, { name: "Misty", description: "The atmosphere is filled with a light mist or fog, diffusing light." }, { name: "Foggy", description: "A dense fog obscures visibility, creating a mysterious or moody atmosphere." }, { name: "Rainy", description: "The weather condition is rain, with visible raindrops and wet surfaces." }, { name: "Snowy", description: "The environment is covered in snow, with possibly falling snowflakes." }, ], }, profileRotation: { title: 'Select a Rotation', options: [ { name: "Front (0°)", value: 0, description: "The subject faces the camera directly." }, { name: "Front-Right 3/4 (45°)", value: 45, description: "The subject is turned slightly to their left, showing 3/4 of their face from the right." }, { name: "Right Profile (90°)", value: 90, description: "The subject looks left, showing their right profile." }, { name: "Back-Right 3/4 (135°)", value: 135, description: "Mostly turned away, showing the back of their head and part of their right cheek." }, { name: "Back (180°)", value: 180, description: "The subject's back is completely to the camera." }, { name: "Back-Left 3/4 (-135°)", value: -135, description: "Mostly turned away, showing the back of their head and part of their left cheek." }, { name: "Left Profile (-90°)", value: -90, description: "The subject looks right, showing their left profile." }, { name: "Front-Left 3/4 (-45°)", value: -45, description: "The subject is turned slightly to their right, showing 3/4 of their face from the left." }, ] }, profileTilt: { title: 'Select a Tilt', options: [ { name: "Level (0°)", value: 0, description: "The subject's head is level, looking straight ahead." }, { name: "Slightly Up (15°)", value: 15, description: "The subject's head is tilted slightly upwards." }, { name: "Looking Up (45°)", value: 45, description: "The subject is looking up at a significant angle." }, { name: "Straight Up (90°)", value: 90, description: "The subject is looking directly upwards." }, { name: "Slightly Down (-15°)", value: -15, description: "The subject's head is tilted slightly downwards." }, { name: "Looking Down (-45°)", value: -45, description: "The subject is looking down at a significant angle." }, { name: "Straight Down (-90°)", value: -90, description: "The subject is looking directly downwards." }, ] } }; // --- DOM Element Selection --- const promptEl = document.querySelector('#prompt-input') as HTMLTextAreaElement; const promptTagsContainer = document.querySelector('#prompt-tags-container') as HTMLDivElement; const generateButton = document.querySelector('#generate-button') as HTMLButtonElement; const resetButton = document.querySelector('#reset-button') as HTMLButtonElement; const outputGallery = document.querySelector('#output-gallery') as HTMLDivElement; const outputActions = document.querySelector('#output-actions') as HTMLDivElement; const downloadButton = document.querySelector('#download-button') as HTMLButtonElement; const editButton = document.querySelector('#edit-button') as HTMLButtonElement; const zoomButton = document.querySelector('#zoom-button') as HTMLButtonElement; const regenerateButton = document.querySelector('#regenerate-button') as HTMLButtonElement; const outputPreviewImage = document.querySelector('#output-preview-image') as HTMLImageElement; const outputThumbnails = document.querySelector('#output-thumbnails') as HTMLDivElement; const outputPlaceholder = document.querySelector('#output-placeholder') as HTMLDivElement; const imageUploadInput = document.querySelector('#image-upload') as HTMLInputElement; const replaceImageInput = document.querySelector('#replace-image-input') as HTMLInputElement; const imagePreviewsContainer = document.querySelector('#image-previews') as HTMLDivElement; const imageCountEl = document.querySelector('#image-count') as HTMLSpanElement; const statusEl = document.querySelector('#status') as HTMLDivElement; const loadingOverlay = document.querySelector('#loading-overlay') as HTMLDivElement; const loadingTextEl = document.querySelector('#loading-text') as HTMLSpanElement; const cancelGenerationButton = document.querySelector('#cancel-generation-button') as HTMLButtonElement; // Reference Image Views const compactViewBtn = document.querySelector('#compact-view-btn') as HTMLButtonElement; const advancedViewBtn = document.querySelector('#advanced-view-btn') as HTMLButtonElement; // Prompt Actions const copyPromptButton = document.querySelector('#copy-prompt-button') as HTMLButtonElement; const expandPromptButton = document.querySelector('#expand-prompt-button') as HTMLButtonElement; const editPromptButton = document.querySelector('#edit-prompt-button') as HTMLButtonElement; const promptModal = document.querySelector('#prompt-modal') as HTMLDivElement; const promptModalCloseButton = document.querySelector('#prompt-modal-close-button') as HTMLButtonElement; const fullPromptText = document.querySelector('#full-prompt-text') as HTMLParagraphElement; const optimizePromptButton = document.querySelector('#optimize-prompt-button') as HTMLButtonElement; const refinePromptButton = document.querySelector('#refine-prompt-button') as HTMLButtonElement; const autoInferToggle = document.querySelector('#auto-infer-toggle') as HTMLInputElement; const noTextToggle = document.querySelector('#no-text-toggle') as HTMLInputElement; const noArtifactsToggle = document.querySelector('#no-artifacts-toggle') as HTMLInputElement; // Edit Prompt Modal const editPromptModal = document.querySelector('#edit-prompt-modal') as HTMLDivElement; const editPromptModalCloseButton = document.querySelector('#edit-prompt-modal-close-button') as HTMLButtonElement; const currentPromptDisplay = document.querySelector('#current-prompt-display') as HTMLParagraphElement; const editPromptInstructions = document.querySelector('#edit-prompt-instructions') as HTMLTextAreaElement; const submitPromptEditButton = document.querySelector('#submit-prompt-edit-button') as HTMLButtonElement; // Sliders & Controls const strengthSlider = document.querySelector('#strength-slider') as HTMLInputElement; const sharpnessSlider = document.querySelector('#sharpness-slider') as HTMLInputElement; const fidelitySlider = document.querySelector('#fidelity-slider') as HTMLInputElement; const allSliders = [strengthSlider, sharpnessSlider, fidelitySlider]; const advancedSettingsControls = document.querySelector('#advanced-settings-controls') as HTMLElement; const numberOfImagesControls = document.querySelector('#number-of-images-controls') as HTMLElement; const aspectRatioControls = document.querySelector('#aspect-ratio-controls') as HTMLElement; const fixAllStylesToggle = document.querySelector('#fix-all-styles-toggle') as HTMLInputElement; const resetArtisticControlsButton = document.querySelector('#reset-artistic-controls-button') as HTMLButtonElement; // Selection Modal const selectionModal = document.querySelector('#selection-modal') as HTMLDivElement; const modalTitle = document.querySelector('#modal-title') as HTMLHeadingElement; const modalCloseButton = document.querySelector('#modal-close-button') as HTMLButtonElement; const modalGrid = document.querySelector('#modal-grid') as HTMLDivElement; const modalSearchInput = document.querySelector('#modal-search-input') as HTMLInputElement; const modalTabsContainer = document.querySelector('#modal-tabs-container') as HTMLDivElement; // Alert Modal const appAlertModal = document.querySelector('#app-alert-modal') as HTMLDivElement; const appAlertMessage = document.querySelector('#app-alert-message') as HTMLParagraphElement; const appAlertCloseButton = document.querySelector('#app-alert-close-button') as HTMLButtonElement; const appAlertOkButton = document.querySelector('#app-alert-ok-button') as HTMLButtonElement; // Edit Modal const editImageModal = document.querySelector('#edit-image-modal') as HTMLDivElement; const editModalCloseButton = document.querySelector('#edit-modal-close-button') as HTMLButtonElement; const editCanvas = document.querySelector('#edit-canvas') as HTMLCanvasElement; const editCanvasContainer = document.querySelector('#edit-canvas-container') as HTMLDivElement; const editPromptInput = document.querySelector('#edit-prompt-input') as HTMLTextAreaElement; const submitEditButton = document.querySelector('#submit-edit-button') as HTMLButtonElement; const editToolbar = document.querySelector('.edit-toolbar') as HTMLDivElement; const brushSizeSlider = document.querySelector('#brush-size-slider') as HTMLInputElement; const brushSizeValue = document.querySelector('#brush-size-value') as HTMLSpanElement; const customBrushCursor = document.querySelector('#custom-brush-cursor') as HTMLDivElement; // Zoom Modal const zoomModal = document.querySelector('#zoom-modal') as HTMLDivElement; const zoomModalCloseButton = document.querySelector('#zoom-modal-close-button') as HTMLButtonElement; const zoomedImage = document.querySelector('#zoomed-image') as HTMLImageElement; // History View const historyLink = document.querySelector('#history-link') as HTMLAnchorElement; const historyContainer = document.querySelector('#history-container') as HTMLDivElement; const historyGridContainer = document.querySelector('#history-grid-container') as HTMLDivElement; const historyEmptyState = document.querySelector('#history-empty-state') as HTMLDivElement; // Studio Switching const imageStudioLink = document.querySelector('#image-studio-link') as HTMLAnchorElement; const promptStudioLink = document.querySelector('#prompt-studio-link') as HTMLAnchorElement; const imageStudioView = document.querySelector('main.main-content') as HTMLElement; const promptStudioContainer = document.querySelector('#prompt-studio-container') as HTMLDivElement; const outputPanel = document.querySelector('.output-panel') as HTMLDivElement; // Prompt Studio - Image to Prompt const i2pImageUpload = document.querySelector('#i2p-image-upload') as HTMLInputElement; const i2pDropZoneContent = document.querySelector('#i2p-drop-zone-content') as HTMLDivElement; const i2pPreview = document.querySelector('#i2p-preview') as HTMLDivElement; const i2pPromptOutput = document.querySelector('#i2p-prompt-output') as HTMLTextAreaElement; const i2pActions = document.querySelector('#i2p-actions') as HTMLDivElement; const i2pCopyButton = document.querySelector('#i2p-copy-button') as HTMLButtonElement; const i2pExpandButton = document.querySelector('#i2p-expand-button') as HTMLButtonElement; const i2pGenerateButton = document.querySelector('#i2p-generate-button') as HTMLButtonElement; // Model Selection const modelButtonT2I = document.querySelector('#model-button-t2i') as HTMLButtonElement; const modelButtonI2I = document.querySelector('#model-button-i2i') as HTMLButtonElement; // God Mode const godModeToggle = document.querySelector('#god-mode-toggle') as HTMLButtonElement; const godModeSliders = document.querySelector('#god-mode-sliders') as HTMLDivElement; const xRotationSlider = document.querySelector('#x-rotation-slider') as HTMLInputElement; const yTiltSlider = document.querySelector('#y-tilt-slider') as HTMLInputElement; const headOrientationCanvas = document.querySelector('#head-orientation-canvas') as HTMLCanvasElement; // --- State Variables --- let referenceImages: ReferenceImage[] = []; const controlState: { [key: string]: any } = { artisticStyle: new Set(), shotType: new Set(), shotAngle: new Set(), lightingStyle: new Set(), lensType: new Set(), timeAndSetting: new Set(), profileRotation: new Set(['Front (0°)']), profileTilt: new Set(['Level (0°)']), xRotation: 0, yTilt: 0, count: '1', aspect: '1:1', }; const fixStyleState: { [key: string]: boolean } = { artisticStyle: false, shotType: false, shotAngle: false, lightingStyle: false, lensType: false, timeAndSetting: false, profileRotation: false, profileTilt: false, }; let currentModel: 't2i' | 'i2i' = 't2i'; let currentModalGroup = ''; let originalPromptForEdit = ''; let currentGeneratedImages: { src: string }[] = []; let history: HistoryItem[] = []; let db: IDBDatabase | null = null; let autoInferSettings = false; let noTextToggleState = false; let noArtifactsToggleState = false; let imagePreviewMode: 'advanced' | 'compact' = 'advanced'; let isGenerationCancelled = false; let isGodModeActive = false; let headGroup: THREE.Group; // For the 3D head model // Edit Canvas State const editCtx = editCanvas.getContext('2d', { willReadFrequently: true })!; let maskCanvas: HTMLCanvasElement; let maskCtx: CanvasRenderingContext2D; let editImageSource: HTMLImageElement | null = null; let isDrawing = false; let isPanning = false; let currentEditTool = 'brush'; let brushSize = 30; let lastMousePos = { x: 0, y: 0 }; let cameraOffset = { x: 0, y: 0 }; let cameraZoom = 1; const ZOOM_SENSITIVITY = 0.005; // --- API Key Handling --- async function openApiKeyDialog() { showStatusError('API key not configured. Please set the API_KEY environment variable.'); } async function displayGeneratedImages(images: { src: string }[], selectedIndex = 0) { if (images.length === 0) { showStatusError('No images were generated.'); return; } currentGeneratedImages = [...images]; for (const img of images) { await addToHistory(img.src); } outputThumbnails.innerHTML = ''; outputPreviewImage.src = images[selectedIndex].src; outputActions.classList.remove('hidden'); images.forEach((image, index) => { const thumb = document.createElement('img'); thumb.src = image.src; thumb.alt = `Generated image variation ${index + 1}`; thumb.className = 'thumbnail-image'; thumb.addEventListener('click', () => { outputPreviewImage.src = image.src; document.querySelectorAll('.thumbnail-image').forEach(t => t.classList.remove('selected')); thumb.classList.add('selected'); }); if (index === selectedIndex) { thumb.classList.add('selected'); } outputThumbnails.appendChild(thumb); }); outputPlaceholder.style.display = 'none'; outputGallery.classList.remove('hidden'); } function buildPrompt(): string { const userText = promptEl.value.trim(); const tagsForPrompt: string[] = []; const mandatoryDirectives: string[] = []; const detailedGuides: string[] = []; const settingMapping: { [key: string]: string } = { artisticStyle: 'Artistic Style', shotType: 'Shot Type', shotAngle: 'Shot Angle', lightingStyle: 'Lighting Style', lensType: 'Lens Type', timeAndSetting: 'Time & Setting', profileRotation: 'Profile Rotation', profileTilt: 'Profile Tilt', }; const addDirective = (group: string, value: string) => { const optionData = MODAL_OPTIONS[group]?.options.find(opt => opt.name === value); if (group === 'profileRotation' || group === 'profileTilt') { const axis = group === 'profileRotation' ? 'Horizontal' : 'Vertical'; const degrees = optionData?.value ?? 0; let mandatoryText = `- **Orientation (${axis})**: This is a critical instruction. The subject's pose MUST be set to exactly ${degrees} degrees. The tag is "${value}".`; if (degrees === 0 && controlState.xRotation === 0 && controlState.yTilt === 0) { mandatoryText += ` This means the subject MUST be looking directly at the camera, facing perfectly forward.`; } mandatoryDirectives.push(mandatoryText); } else { mandatoryDirectives.push(`- **${settingMapping[group]}**: This is a strict requirement. The image MUST use: "${value}".`); } if (optionData) { detailedGuides.push(`- **${optionData.name} (${settingMapping[group]})**: ${optionData.description}`); } }; // Process normal artistic controls for (const group in settingMapping) { if (controlState[group] instanceof Set) { const selectedValues = Array.from(controlState[group] as Set); if (selectedValues.length > 0) { tagsForPrompt.push(...selectedValues); selectedValues.forEach(value => addDirective(group, value)); } } } // Process "Fix Style" toggles for (const group in fixStyleState) { if (fixStyleState[group]) { const groupName = settingMapping[group] || group.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()); const fixInstruction = `Fixed ${groupName}`; tagsForPrompt.push(fixInstruction); mandatoryDirectives.push(`- **${groupName}**: This is a critical instruction. You MUST perfectly replicate the ${groupName.toLowerCase()} from the provided reference image. Do not deviate.`); } } // Process God Mode for Profile Angle if (isGodModeActive) { const xRot = controlState.xRotation as number; const yTilt = controlState.yTilt as number; tagsForPrompt.push(`X-Rot: ${xRot}°`, `Y-Tilt: ${yTilt}°`); let poseDirective = `- **Subject Pose**: This is a critical instruction. You MUST render the subject with a precise pose. Horizontal Rotation: ${xRot} degrees. Vertical Tilt: ${yTilt} degrees. 0 degrees is facing forward. For Rotation, positive values turn the subject to their left (showing more of their right side), and negative values turn them to their right. For Tilt, positive values tilt the head up, negative values tilt down.`; if (xRot === 0 && yTilt === 0) { poseDirective += ` The subject MUST be looking directly at the camera, facing perfectly forward.` } mandatoryDirectives.push(poseDirective); } // Add new negative prompt directives if (noTextToggleState) { mandatoryDirectives.push(`- **No Text**: This is a critical rule. The image MUST NOT contain any letters, numbers, words, text, or characters of any language. Ensure the final image is completely free of all textual elements.`); } if (noArtifactsToggleState) { mandatoryDirectives.push(`- **No Artifacts**: This is a critical rule. The image must be clean and MUST ONLY contain elements explicitly described in the prompt. Do not add any extra, random, distorted, or malformed objects, patterns, or visual noise. Avoid unwanted elements.`); } // Combine the user text with tags for a concise main prompt let mainPrompt = userText; if (tagsForPrompt.length > 0) { mainPrompt += (mainPrompt ? ", " : "") + tagsForPrompt.join(", "); } if (!mainPrompt) { mainPrompt = 'A high quality, detailed, photorealistic image.'; } let finalPrompt = mainPrompt; // Add mandatory directives block if any exist if (mandatoryDirectives.length > 0) { finalPrompt += `\n\n---\n**MANDATORY DIRECTIVES: These rules are not optional. You MUST follow them precisely.**\n${mandatoryDirectives.join('\n')}`; } // Add detailed style guide if any exist if (detailedGuides.length > 0) { finalPrompt += `\n\n---\n**DETAILED STYLE GUIDE: Use these descriptions for context.**\n${detailedGuides.join('\n')}`; } // Add sliders for T2I if (currentModel === 't2i') { const strength = parseFloat(strengthSlider.value); const sharpness = parseFloat(sharpnessSlider.value); const fidelity = parseFloat(fidelitySlider.value); finalPrompt += `\n\n---\n**GENERATION PARAMETERS:**\n- Generation Strength: ${strength.toFixed(2)}\n- Image Sharpness: ${sharpness.toFixed(2)}\n- Prompt Fidelity: ${fidelity.toFixed(1)}`; } return finalPrompt; } function areArtisticControlsSelected(): boolean { for (const key in controlState) { if (key === 'count' || key === 'aspect') continue; if (key === 'xRotation' || key === 'yTilt') continue; const value = controlState[key]; if (value instanceof Set && value.size > 0) { // Special case for profileTilt which has a default if (key === 'profileTilt' && value.has('Level (0°)')) { // If it's the only one selected besides tilt, return false const otherSelections = Object.keys(controlState).some(k => { if (k === 'profileTilt' || k === 'count' || k === 'aspect' || k === 'xRotation' || k === 'yTilt') return false; return (controlState[k] instanceof Set && controlState[k].size > 0); }); if (!otherSelections) continue; } return true; } } for (const key in fixStyleState) { if (fixStyleState[key]) return true; } if (isGodModeActive && (controlState.xRotation !== 0 || controlState.yTilt !== 0)) { return true; } return false; } async function handleGenerateClick() { if (!promptEl.value.trim() && !areArtisticControlsSelected()) { showAppAlert('Please enter a prompt or select at least one artistic control.'); return; } if (currentModel === 'i2i' && referenceImages.length === 0) { showAppAlert('Please add a reference image for Image-to-Image mode.'); return; } const apiKey = process.env.API_KEY; if (!apiKey) { await openApiKeyDialog(); return; } isGenerationCancelled = false; statusEl.textContent = ''; loadingOverlay.classList.remove('hidden'); setControlsDisabled(true); originalPromptForEdit = buildPrompt(); const ai = new GoogleGenAI({ apiKey }); try { let generatedImages: { src: string }[] = []; // --- TRUE IMAGE-TO-IMAGE PATH --- if (currentModel === 'i2i' && referenceImages.length > 0) { loadingTextEl.textContent = 'Generating with I2I model...'; const parts: Part[] = []; const baseImage = referenceImages.find(img => img.role === 'base'); if (!baseImage) { throw new Error("Please select one image as the 'Base' for composition."); } // Add overall prompt first parts.push({ text: originalPromptForEdit }); // Add Base Image and its instructions parts.push({ inlineData: { data: baseImage.data, mimeType: baseImage.mimeType } }); let basePrompt = "This is the BASE image, the main canvas for the composition."; if (baseImage.prompt) basePrompt += ` Instructions for base image: ${baseImage.prompt}`; if (baseImage.isFace) basePrompt += " Preserve the facial features from the base image."; parts.push({ text: basePrompt }); // Add all reference images and their instructions referenceImages.filter(img => img.role === 'reference').forEach(refImg => { parts.push({ inlineData: { data: refImg.data, mimeType: refImg.mimeType } }); let refPrompt = "This is a REFERENCE image. Use elements from it to modify the base image."; if (refImg.prompt) refPrompt += ` Instructions for this reference image: ${refImg.prompt}`; if (refImg.isFace) refPrompt += " Preserve the facial features from this reference image and incorporate them into the final composition."; parts.push({ text: refPrompt }); }); if (isGenerationCancelled) return; const response = await ai.models.generateContent({ model: 'gemini-2.5-flash-image', contents: { parts: parts }, config: { responseModalities: [Modality.IMAGE], }, }); if (isGenerationCancelled) return; if (response.promptFeedback?.blockReason) { throw new Error(`Request was blocked: ${response.promptFeedback.blockReason}. ${response.promptFeedback.blockReasonMessage || ''}`.trim()); } const imageResponsePart = response.candidates?.[0]?.content?.parts?.find(part => part.inlineData); if (imageResponsePart?.inlineData) { const newImageSrc = `data:${imageResponsePart.inlineData.mimeType};base64,${imageResponsePart.inlineData.data}`; generatedImages = [{ src: newImageSrc }]; } else { const textResponse = response.text?.trim(); let errorMessage = 'The Image-to-Image model did not return an image.'; if (textResponse) { errorMessage += ` It might have only returned text: "${textResponse}"`; } throw new Error(errorMessage); } } // --- TEXT-TO-IMAGE PATH --- else { loadingTextEl.textContent = 'Generating with T2I model...'; const numberOfImages = parseInt(controlState.count, 10); if (isGenerationCancelled) return; const response = await ai.models.generateImages({ model: 'imagen-4.0-generate-001', prompt: originalPromptForEdit, config: { numberOfImages: numberOfImages, aspectRatio: controlState.aspect, }, }); if (isGenerationCancelled) return; const images = response.generatedImages; if (images === undefined || images.length === 0) { throw new Error('No images were generated. The prompt may have been blocked.'); } generatedImages = images.map(image => ({ src: `data:image/jpeg;base64,${image.image.imageBytes}` })); } if (isGenerationCancelled) return; await displayGeneratedImages(generatedImages); if (isGenerationCancelled) return; statusEl.textContent = 'Image(s) generated successfully.'; } catch (e: any) { if (isGenerationCancelled) { console.log("Generation cancelled, ignoring error.", e); return; } console.error('Image generation failed:', e); let errorMessage = 'An unknown error occurred during image generation.'; if (e?.error?.message) { errorMessage = e.error.message; if (e.error.status === 'RESOURCE_EXHAUSTED') { errorMessage += '
You have exceeded your request limit. Please wait and try again, or generate fewer images.'; } } else if (e instanceof Error) { errorMessage = e.message; } else if (typeof e === 'string') { errorMessage = e; } if (errorMessage.includes('blockReason') || errorMessage.includes('PROHIBITED_CONTENT')) { errorMessage = 'Your request was blocked due to the safety policy. Please adjust your prompt.'; } showStatusError(`Error: ${errorMessage}`); } finally { if (!isGenerationCancelled) { loadingOverlay.classList.add('hidden'); setControlsDisabled(false); } } } // --- Event Listeners & UI Handlers --- document.addEventListener('DOMContentLoaded', () => { generateButton.addEventListener('click', handleGenerateClick); resetButton.addEventListener('click', handleReset); cancelGenerationButton.addEventListener('click', () => { isGenerationCancelled = true; loadingOverlay.classList.add('hidden'); setControlsDisabled(false); showStatusError("Generation cancelled."); }); window.lucide.createIcons(); allSliders.forEach(slider => { const valueEl = slider.nextElementSibling as HTMLSpanElement; const updateSliderValue = () => { valueEl.textContent = parseFloat(slider.value).toFixed(slider.step.includes('.') ? 2 : 1); }; updateSliderValue(); slider.addEventListener('input', updateSliderValue); }); setupTagControls(); setupImageUpload(); setupReferenceImageViews(); setupModelSelection(); setupOutputActions(); setupPromptActions(); setupSelectionModal(); setupAppAlertModal(); setupEditModal(); setupZoomModal(); setupStudioSwitcher(); setupPromptStudio(); setupFixStyleToggles(); setupNegativePromptToggles(); setupGodMode(); setupArtisticControlsReset(); initDB(); headGroup = initHeadOrientationVisualizer(); // Initial state for prompt buttons editPromptButton.disabled = true; optimizePromptButton.disabled = true; refinePromptButton.disabled = true; }); function setupTagControls() { document.querySelector('.scroll-container')!.addEventListener('click', (e) => { const target = e.target as HTMLElement; const tagButton = target.closest('.tag-button'); if (!tagButton || tagButton.classList.contains('add-tag-button')) return; const groupEl = tagButton.closest('.tag-group') as HTMLElement; const container = tagButton.closest('.tag-container') as HTMLElement; if (!groupEl || !container) return; const group = groupEl.dataset.group; const mode = container.dataset.mode; const value = tagButton.textContent!.trim(); if (!group) return; if (fixStyleState[group]) { return; // Block interaction if style is fixed } const handleSelection = (stateSet: Set) => { const isActive = tagButton.classList.contains('active'); if (mode === 'single') { if (isActive) { // If already active, deselect stateSet.clear(); tagButton.classList.remove('active'); } else { // Select a new one stateSet.clear(); stateSet.add(value); container.querySelectorAll('.tag-button.active').forEach(b => b.classList.remove('active')); tagButton.classList.add('active'); } } else { // Multi-select if (isActive) { stateSet.delete(value); tagButton.classList.remove('active'); } else { stateSet.add(value); tagButton.classList.add('active'); } } }; // Handle artistic controls which are Sets if (controlState[group] instanceof Set) { handleSelection(controlState[group]); // Sync with God Mode Sliders if it's a profile control if (group === 'profileRotation' || group === 'profileTilt') { const optionData = MODAL_OPTIONS[group]?.options.find(opt => opt.name === value); if (optionData && typeof optionData.value === 'number') { const slider = group === 'profileRotation' ? xRotationSlider : yTiltSlider; slider.value = String(optionData.value); slider.dispatchEvent(new Event('input', { bubbles: true })); } } renderPromptTags(); } // Handle simple string controls (count, aspect) else if (mode === 'single') { controlState[group] = value; container.querySelectorAll('.tag-button.active').forEach(b => b.classList.remove('active')); tagButton.classList.add('active'); } }); } async function handleImageFiles(files: File[]) { if (referenceImages.length + files.length > 10) { showAppAlert('You can upload a maximum of 10 images.'); return; } for (const file of files) { if (!file.type.startsWith('image/')) continue; const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target?.result as string; if (!dataUrl) return; const [header, base64Data] = dataUrl.split(','); const mimeType = header.match(/:(.*?);/)?.[1] || 'image/jpeg'; // First image is base, subsequent are reference const isFirstImage = referenceImages.length === 0; referenceImages.push({ id: `ref-${Date.now()}-${Math.random()}`, name: file.name, data: base64Data, mimeType, role: isFirstImage ? 'base' : 'reference', prompt: '', isFace: true, }); renderImagePreviews(); }; reader.readAsDataURL(file); } } function setupImageUpload() { const dropZone = document.querySelector('label[for="image-upload"]') as HTMLLabelElement; imageUploadInput.addEventListener('change', async () => { if (!imageUploadInput.files) return; handleImageFiles(Array.from(imageUploadInput.files)); imageUploadInput.value = ''; // Reset input }); // Drag and drop events ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false); }); ['dragenter', 'dragover'].forEach(eventName => { dropZone.addEventListener(eventName, () => dropZone.classList.add('drag-over'), false); }); ['dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, () => dropZone.classList.remove('drag-over'), false); }); dropZone.addEventListener('drop', e => { const dt = e.dataTransfer; if (dt) { handleImageFiles(Array.from(dt.files)); } }, false); } function setupReferenceImageViews() { compactViewBtn.addEventListener('click', () => { imagePreviewMode = 'compact'; compactViewBtn.classList.add('active'); advancedViewBtn.classList.remove('active'); renderImagePreviews(); }); advancedViewBtn.addEventListener('click', () => { imagePreviewMode = 'advanced'; advancedViewBtn.classList.add('active'); compactViewBtn.classList.remove('active'); renderImagePreviews(); }); replaceImageInput.addEventListener('change', async (e) => { const input = e.target as HTMLInputElement; const file = input.files?.[0]; const idToReplace = input.dataset.replaceId; input.value = ''; // Reset input if (!file || !idToReplace) return; const reader = new FileReader(); reader.onload = (event) => { const dataUrl = event.target?.result as string; if (!dataUrl) return; const [header, base64Data] = dataUrl.split(','); const mimeType = header.match(/:(.*?);/)?.[1] || 'image/jpeg'; const imageIndex = referenceImages.findIndex(img => img.id === idToReplace); if (imageIndex > -1) { referenceImages[imageIndex] = { ...referenceImages[imageIndex], name: file.name, data: base64Data, mimeType, }; renderImagePreviews(); } }; reader.readAsDataURL(file); }); } function setupModelSelection() { modelButtonT2I.addEventListener('click', () => { // Prevent manual switching to T2I if images are present, as it's confusing. // The user should remove images to go back to T2I. if (referenceImages.length > 0) { showAppAlert('Please remove reference images to use Text-to-Image mode.'); return; } updateModelSelection('t2i'); }); modelButtonI2I.addEventListener('click', () => { if (referenceImages.length === 0) { showAppAlert('Please upload a reference image to use Image-to-Image mode.'); return; } updateModelSelection('i2i'); }); } function updateModelSelection(newModel: 't2i' | 'i2i') { currentModel = newModel; const isI2I = newModel === 'i2i'; // Update button visuals modelButtonT2I.classList.toggle('active', !isI2I); modelButtonI2I.classList.toggle('active', isI2I); // Disable/enable controls based on model capabilities advancedSettingsControls.classList.toggle('disabled', isI2I); numberOfImagesControls.classList.toggle('disabled', isI2I); aspectRatioControls.classList.toggle('disabled', isI2I); // Show/hide fix style toggles document.querySelectorAll('.fix-style-controls-container').forEach(el => { el.style.display = isI2I ? 'flex' : 'none'; }); } function setupOutputActions() { downloadButton.addEventListener('click', () => { const link = document.createElement('a'); link.href = outputPreviewImage.src; link.download = `wowimage-${Date.now()}.png`; link.click(); }); editButton.addEventListener('click', openEditModal); zoomButton.addEventListener('click', () => { if (outputPreviewImage.src && !outputPreviewImage.src.endsWith(window.location.href)) { zoomedImage.src = outputPreviewImage.src; zoomModal.classList.remove('hidden'); } }); regenerateButton.addEventListener('click', handleGenerateClick); } function setupPromptActions() { promptEl.addEventListener('input', () => { const hasText = promptEl.value.trim().length > 0; optimizePromptButton.disabled = !hasText; refinePromptButton.disabled = !hasText; editPromptButton.disabled = !hasText; }); promptEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); generateButton.click(); } }); submitPromptEditButton.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitPromptEditButton.click(); } }); promptEl.addEventListener('blur', () => { const text = promptEl.value.trim(); if (autoInferSettings && text.length > 50 && currentModel === 't2i') { // Only infer for T2I inferSettingsFromPrompt(text); } }); autoInferToggle.addEventListener('change', () => { autoInferSettings = autoInferToggle.checked; }); optimizePromptButton.addEventListener('click', () => handlePromptEnhancement('optimize')); refinePromptButton.addEventListener('click', () => handlePromptEnhancement('refine')); expandPromptButton.addEventListener('click', () => { fullPromptText.textContent = buildPrompt(); promptModal.classList.remove('hidden'); }); editPromptButton.addEventListener('click', () => { currentPromptDisplay.textContent = promptEl.value; editPromptInstructions.value = ''; editPromptModal.classList.remove('hidden'); }); copyPromptButton.addEventListener('click', () => { navigator.clipboard.writeText(buildPrompt()).then(() => { showStatusError('Full prompt copied!'); setTimeout(() => statusEl.innerHTML = '', 2000); }); }); promptModalCloseButton.addEventListener('click', () => promptModal.classList.add('hidden')); promptModal.addEventListener('click', e => { if (e.target === promptModal) promptModal.classList.add('hidden'); }); // Edit Prompt Modal Listeners editPromptModalCloseButton.addEventListener('click', () => editPromptModal.classList.add('hidden')); submitPromptEditButton.addEventListener('click', () => handlePromptEnhancement('iterative')); } function setupNegativePromptToggles() { noTextToggle.addEventListener('change', () => { noTextToggleState = noTextToggle.checked; }); noArtifactsToggle.addEventListener('change', () => { noArtifactsToggleState = noArtifactsToggle.checked; }); } async function handlePromptEnhancement(type: 'optimize' | 'refine' | 'iterative') { const originalPrompt = type === 'iterative' ? currentPromptDisplay.textContent!.trim() : promptEl.value.trim(); if (!originalPrompt) return; const apiKey = process.env.API_KEY; if (!apiKey) { await openApiKeyDialog(); return; } const button = { 'optimize': optimizePromptButton, 'refine': refinePromptButton, 'iterative': submitPromptEditButton, }[type]; const originalHTML = button.innerHTML; button.innerHTML = `
`; button.disabled = true; if (type === 'iterative') editPromptModal.classList.add('hidden'); try { const ai = new GoogleGenAI({ apiKey }); let systemInstruction = ''; let contents = originalPrompt; let model = 'gemini-2.5-flash'; const config: { systemInstruction: string, thinkingConfig?: { thinkingBudget: number } } = { systemInstruction }; switch(type) { case 'optimize': model = 'gemini-2.5-pro'; config.thinkingConfig = { thinkingBudget: 32768 }; systemInstruction = 'You are a prompt engineering expert for an AI image generator. Rewrite the user\'s prompt to be highly detailed, visually rich, and structured for the best possible image generation. Add specific details about style, lighting, composition, and mood.'; break; case 'refine': systemInstruction = 'You are an AI prompt safety expert. Rewrite the user\'s prompt to be fully compliant with AI safety policies, removing or rephrasing any words that might be flagged, while preserving the core artistic intent. Use creative synonyms and alternative phrasing to bypass content restrictions without losing the essence of the original idea.'; break; case 'iterative': const instructions = editPromptInstructions.value.trim(); if (!instructions) throw new Error("Improvement instructions are empty."); systemInstruction = 'You are a prompt engineering expert. The user has an existing prompt and wants to improve it. Rewrite the prompt according to the user\'s instructions for improvement.'; contents = `EXISTING PROMPT: "${originalPrompt}"\n\nINSTRUCTIONS: "${instructions}"`; break; } config.systemInstruction = systemInstruction; const response = await ai.models.generateContent({ model: model, contents: contents, config: config, }); const newPrompt = response.text.trim(); if (newPrompt) { promptEl.value = newPrompt; } else { throw new Error('AI did not return a suggestion.'); } } catch (e) { console.error(`Prompt ${type} failed:`, e); showStatusError(`Error: Could not ${type} prompt.`); } finally { button.innerHTML = originalHTML; // This is a more robust way to restore icons after they have been created. if (type === 'optimize') { button.innerHTML = `Optimize`; } else if (type === 'refine') { button.innerHTML = `Refine for AI`; } button.disabled = false; window.lucide.createIcons(); } } async function inferSettingsFromPrompt(promptText: string) { const apiKey = process.env.API_KEY; if (!apiKey) return; optimizePromptButton.disabled = true; optimizePromptButton.innerHTML = `
Inferring...`; try { const ai = new GoogleGenAI({ apiKey }); let systemInstruction = `You are an assistant that analyzes an image prompt and extracts keywords. Your response must be a valid JSON object. For each category, provide an array of strings with exact matches from the allowed options based on the prompt.\n\n`; const properties: {[key: string]: any} = {}; for (const key in MODAL_OPTIONS) { const group = MODAL_OPTIONS[key as keyof typeof MODAL_OPTIONS]; systemInstruction += `- ${key}: [${group.options.map(o => `"${o.name}"`).join(', ')}]\n`; properties[key] = { type: Type.ARRAY, items: { type: Type.STRING }}; } const response = await ai.models.generateContent({ model: 'gemini-flash-lite-latest', contents: promptText, config: { systemInstruction, responseMimeType: "application/json", responseSchema: { type: Type.OBJECT, properties } }, }); const jsonStr = response.text.trim(); const settings = JSON.parse(jsonStr); for (const group in settings) { if (controlState[group] instanceof Set && Array.isArray(settings[group])) { const stateSet = controlState[group] as Set; stateSet.clear(); settings[group].forEach((val: string) => stateSet.add(val)); } } updateTagsUIFromState(); } catch (e) { console.error('Failed to infer settings from prompt:', e); } finally { optimizePromptButton.innerHTML = `Optimize`; optimizePromptButton.disabled = false; window.lucide.createIcons(); } } function updateTagsUIFromState() { // Update button visuals for (const group in controlState) { if (controlState[group] instanceof Set) { const stateSet = controlState[group] as Set; const groupEl = document.querySelector(`.tag-group[data-group="${group}"]`); if (!groupEl) continue; const container = groupEl.querySelector('.tag-container') as HTMLElement; container.querySelectorAll('.tag-button').forEach(btn => { if (!btn.classList.contains('add-tag-button')) { btn.classList.remove('active'); } }); stateSet.forEach(value => { let button = Array.from(container.querySelectorAll('.tag-button')).find(b => b.textContent?.trim() === value) as HTMLButtonElement | undefined; if (!button) { addTagToUI(group, value); button = Array.from(container.querySelectorAll('.tag-button')).find(b => b.textContent?.trim() === value) as HTMLButtonElement | undefined; } button?.classList.add('active'); }); } } // Then render the tags in the prompt box renderPromptTags(); } function handleReset() { promptEl.value = ''; promptEl.dispatchEvent(new Event('input')); referenceImages = []; renderImagePreviews(); strengthSlider.value = '0.8'; sharpnessSlider.value = '0.5'; fidelitySlider.value = '7.5'; allSliders.forEach(slider => slider.dispatchEvent(new Event('input'))); resetArtisticControls(); noTextToggleState = false; noArtifactsToggleState = false; noTextToggle.checked = false; noArtifactsToggle.checked = false; outputGallery.classList.add('hidden'); outputActions.classList.add('hidden'); outputPreviewImage.src = ''; outputThumbnails.innerHTML = ''; outputPlaceholder.style.display = 'flex'; statusEl.textContent = ''; currentGeneratedImages = []; } function setupArtisticControlsReset() { resetArtisticControlsButton.addEventListener('click', resetArtisticControls); } function resetArtisticControls() { const artisticControlGroups = [ 'artisticStyle', 'shotType', 'shotAngle', 'lightingStyle', 'lensType', 'timeAndSetting', 'profileRotation', 'profileTilt' ]; artisticControlGroups.forEach(group => { if (controlState[group] instanceof Set) { controlState[group].clear(); } if (group in fixStyleState) { fixStyleState[group] = false; } }); controlState.profileRotation.add('Front (0°)'); controlState.profileTilt.add('Level (0°)'); controlState.xRotation = 0; controlState.yTilt = 0; // Reset UI document.querySelectorAll('.fix-style-toggle, #fix-all-styles-toggle').forEach(toggle => toggle.checked = false); // Reset God Mode if (isGodModeActive) { godModeToggle.click(); // This will toggle it off and handle UI reset } else { xRotationSlider.value = '0'; yTiltSlider.value = '0'; xRotationSlider.dispatchEvent(new Event('input')); yTiltSlider.dispatchEvent(new Event('input')); } artisticControlGroups.forEach(group => { const groupEl = document.querySelector(`.tag-group[data-group="${group}"]`); if(groupEl) { groupEl.querySelectorAll('.tag-button.active').forEach(b => b.classList.remove('active')); // Set default active for profileTilt if(group === 'profileRotation') { const defaultButton = Array.from(groupEl.querySelectorAll('.tag-button')).find(b => b.textContent?.trim() === 'Front (0°)') as HTMLButtonElement; defaultButton?.classList.add('active'); } if(group === 'profileTilt') { const defaultButton = Array.from(groupEl.querySelectorAll('.tag-button')).find(b => b.textContent?.trim() === 'Level (0°)') as HTMLButtonElement; defaultButton?.classList.add('active'); } } }); updateArtisticControlsUI(); renderPromptTags(); } function renderImagePreviews() { imagePreviewsContainer.innerHTML = ''; if (imagePreviewMode === 'compact') { imagePreviewsContainer.className = 'flex flex-row flex-wrap gap-2 mt-4'; } else { imagePreviewsContainer.className = 'flex flex-col gap-4 mt-4'; } referenceImages.forEach((image) => { if (imagePreviewMode === 'advanced') { const card = document.createElement('div'); card.className = 'reference-image-card-large'; card.dataset.imageId = image.id; // Card Header const header = document.createElement('div'); header.className = 'card-header'; const imgContainer = document.createElement('div'); imgContainer.className = 'preview-image-large-container'; const img = document.createElement('img'); img.src = `data:${image.mimeType};base64,${image.data}`; img.className = 'preview-image-large'; img.alt = image.name; const actionsContainer = document.createElement('div'); actionsContainer.className = 'card-top-actions'; const replaceButton = document.createElement('button'); replaceButton.className = 'card-action-button replace-btn'; replaceButton.innerHTML = '↻'; replaceButton.style.fontSize = '16px'; replaceButton.style.fontWeight = 'bold'; replaceButton.setAttribute('aria-label', `Replace ${image.name}`); replaceButton.onclick = () => { replaceImageInput.dataset.replaceId = image.id; replaceImageInput.click(); }; const removeButton = document.createElement('button'); removeButton.className = 'card-action-button remove-btn'; removeButton.innerHTML = '×'; removeButton.setAttribute('aria-label', `Remove ${image.name}`); removeButton.onclick = () => { referenceImages = referenceImages.filter(ref => ref.id !== image.id); if (image.role === 'base' && referenceImages.length > 0) { referenceImages[0].role = 'base'; } renderImagePreviews(); }; actionsContainer.append(replaceButton, removeButton); imgContainer.append(img, actionsContainer); // Card Controls const controls = document.createElement('div'); controls.className = 'card-controls'; const roleSelector = document.createElement('div'); roleSelector.className = 'role-selector'; const baseButton = document.createElement('button'); baseButton.className = `role-button ${image.role === 'base' ? 'active' : ''}`; baseButton.textContent = 'Base'; baseButton.onclick = () => { referenceImages.forEach(img => img.role = 'reference'); image.role = 'base'; renderImagePreviews(); }; const refButton = document.createElement('button'); refButton.className = `role-button ${image.role === 'reference' ? 'active' : ''}`; refButton.textContent = 'Reference'; refButton.onclick = () => { image.role = 'reference'; if (!referenceImages.some(img => img.role === 'base')) { const firstNonThis = referenceImages.find(img => img.id !== image.id); if(firstNonThis) firstNonThis.role = 'base'; } renderImagePreviews(); }; roleSelector.append(baseButton, refButton); const faceToggle = document.createElement('div'); faceToggle.className = 'face-toggle'; const toggleLabel = document.createElement('span'); toggleLabel.className = 'toggle-label'; toggleLabel.textContent = 'Face Reference'; const switchLabel = document.createElement('label'); switchLabel.className = 'switch'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = image.isFace; checkbox.onchange = () => { image.isFace = checkbox.checked; }; const sliderSpan = document.createElement('span'); sliderSpan.className = 'slider round'; switchLabel.append(checkbox, sliderSpan); faceToggle.append(toggleLabel, switchLabel); controls.append(roleSelector, faceToggle); header.append(imgContainer, controls); const promptInput = document.createElement('textarea'); promptInput.className = 'reference-prompt-input'; promptInput.rows = 2; promptInput.placeholder = 'Optional: specific instructions for this image...'; promptInput.value = image.prompt; promptInput.oninput = () => { image.prompt = promptInput.value; }; card.append(header, promptInput); imagePreviewsContainer.appendChild(card); } else { // Compact View const card = document.createElement('div'); card.className = 'reference-image-card-compact'; card.dataset.imageId = image.id; const img = document.createElement('img'); img.src = `data:${image.mimeType};base64,${image.data}`; img.className = 'preview-image-compact'; img.alt = image.name; const overlay = document.createElement('div'); overlay.className = 'card-actions-overlay'; const replaceButton = document.createElement('button'); replaceButton.className = 'action-button replace'; replaceButton.innerHTML = '↻'; replaceButton.title = `Replace ${image.name}`; replaceButton.onclick = () => { replaceImageInput.dataset.replaceId = image.id; replaceImageInput.click(); }; const removeButton = document.createElement('button'); removeButton.className = 'action-button delete'; removeButton.innerHTML = '×'; removeButton.title = `Remove ${image.name}`; removeButton.onclick = () => { referenceImages = referenceImages.filter(ref => ref.id !== image.id); if (image.role === 'base' && referenceImages.length > 0) { referenceImages[0].role = 'base'; } renderImagePreviews(); }; overlay.append(replaceButton, removeButton); card.append(img, overlay); imagePreviewsContainer.appendChild(card); } }); imageCountEl.textContent = `${referenceImages.length}/10`; if (referenceImages.length > 0) { updateModelSelection('i2i'); } else { // If there are no more images, turn off all fix style toggles let changed = false; for (const group in fixStyleState) { if (fixStyleState[group]) { fixStyleState[group] = false; changed = true; } } if (changed) { document.querySelectorAll('.fix-style-toggle, #fix-all-styles-toggle').forEach(t => t.checked = false); updateArtisticControlsUI(); renderPromptTags(); } updateModelSelection('t2i'); } window.lucide.createIcons(); } // --- Prompt Tags & Fix Style Toggles --- function updateArtisticControlsUI() { document.querySelectorAll('.tag-group[data-group]').forEach(groupEl => { const group = groupEl.dataset.group; if (!group || !(group in fixStyleState)) return; const container = groupEl.querySelector('.tag-container'); const addTagButton = groupEl.querySelector('.add-tag-button'); if (fixStyleState[group]) { container?.classList.add('disabled'); container?.querySelectorAll('.tag-button').forEach(btn => { btn.classList.remove('active'); }); if (addTagButton) addTagButton.disabled = true; } else { container?.classList.remove('disabled'); if (addTagButton) addTagButton.disabled = false; // Re-apply active state from controlState const stateSet = controlState[group] as Set; if (stateSet) { container?.querySelectorAll('.tag-button').forEach(btn => { const value = btn.textContent?.trim(); if (value && stateSet.has(value)) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); } } }); } function setupFixStyleToggles() { document.querySelectorAll('.fix-style-toggle').forEach(toggle => { toggle.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; const group = target.dataset.group; if (!group) return; if (target.checked && referenceImages.length === 0) { showAppAlert('You must upload a reference image to fix a style.'); target.checked = false; return; } fixStyleState[group] = target.checked; if (target.checked) { if (controlState[group] instanceof Set) { (controlState[group] as Set).clear(); } } const allChecked = Object.values(fixStyleState).every(v => v); fixAllStylesToggle.checked = allChecked; renderPromptTags(); updateArtisticControlsUI(); }); }); fixAllStylesToggle.addEventListener('change', () => { if (fixAllStylesToggle.checked && referenceImages.length === 0) { showAppAlert('You must upload reference images to fix all styles.'); fixAllStylesToggle.checked = false; return; } const isChecked = fixAllStylesToggle.checked; Object.keys(fixStyleState).forEach(group => { fixStyleState[group] = isChecked; if (isChecked && controlState[group] instanceof Set) { (controlState[group] as Set).clear(); } }); document.querySelectorAll('.fix-style-toggle').forEach(toggle => { toggle.checked = isChecked; }); renderPromptTags(); updateArtisticControlsUI(); }); } function renderPromptTags() { promptTagsContainer.innerHTML = ''; // Clear existing tags const createTag = (text: string, group: string, type: 'control' | 'fix' | 'god-mode') => { const tagEl = document.createElement('span'); tagEl.className = 'prompt-tag'; tagEl.textContent = text; const closeBtn = document.createElement('button'); closeBtn.className = 'prompt-tag-close'; closeBtn.innerHTML = '×'; closeBtn.setAttribute('aria-label', `Remove ${text}`); closeBtn.onclick = () => { if (type === 'control') { const stateSet = controlState[group] as Set; stateSet.delete(text); updateTagsUIFromState(); // This will deselect button and re-render tags } else if (type === 'fix') { fixStyleState[group] = false; const toggle = document.querySelector(`.fix-style-toggle[data-group="${group}"]`) as HTMLInputElement; if (toggle) toggle.checked = false; fixAllStylesToggle.checked = false; updateArtisticControlsUI(); renderPromptTags(); } else if (type === 'god-mode') { if (group === 'xRotation') { controlState.xRotation = 0; xRotationSlider.value = '0'; xRotationSlider.dispatchEvent(new Event('input', { bubbles: true })); } else if (group === 'yTilt') { controlState.yTilt = 0; yTiltSlider.value = '0'; yTiltSlider.dispatchEvent(new Event('input', { bubbles: true })); } } }; tagEl.appendChild(closeBtn); promptTagsContainer.appendChild(tagEl); }; // Render normal control tags for (const group in controlState) { if (controlState[group] instanceof Set) { const selected = Array.from(controlState[group] as Set); selected.forEach(value => createTag(value, group, 'control')); } } // Render fix style tags const groupNameMapping: { [key: string]: string } = { artisticStyle: "Fixed Artistic Style", shotType: "Fixed Shot Type", shotAngle: "Fixed Shot Angle", lightingStyle: "Fixed Lighting", lensType: "Fixed Lens", timeAndSetting: "Fixed Time & Setting", profileRotation: "Fixed Profile Rotation", profileTilt: "Fixed Profile Tilt", }; for (const group in fixStyleState) { if (fixStyleState[group]) { const readableName = groupNameMapping[group] || `Fixed ${group}`; createTag(readableName, group, 'fix'); } } // Render God Mode tags if (isGodModeActive) { if (controlState.xRotation != 0) { createTag(`X-Rot: ${controlState.xRotation}°`, 'xRotation', 'god-mode'); } if (controlState.yTilt != 0) { createTag(`Y-Tilt: ${controlState.yTilt}°`, 'yTilt', 'god-mode'); } } } // --- Modal Functions --- function setupSelectionModal() { document.querySelector('.scroll-container')?.addEventListener('click', (e) => { const target = e.target as HTMLElement; const addButton = target.closest('.add-tag-button'); if (addButton) { const groupEl = addButton.closest('.tag-group'); if (groupEl instanceof HTMLElement) { const group = groupEl.dataset.group; if (addButton.dataset.modalTarget === 'profileAngle') { openProfileAngleModal(addButton.dataset.modalInitialTab || 'profileRotation'); } else if (group) { openSelectionModal(group); } } } }); modalCloseButton.addEventListener('click', closeSelectionModal); selectionModal.addEventListener('click', (e) => { if (e.target === selectionModal) closeSelectionModal(); }); modalSearchInput.addEventListener('input', (e) => { const searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); document.querySelectorAll('.modal-option-item').forEach(item => { const itemText = item.textContent!.toLowerCase(); (item as HTMLElement).style.display = itemText.includes(searchTerm) ? 'flex' : 'none'; }); }); } function sanitizeForFilename(text: string): string { return text .toLowerCase() .replace(/\((.*?)\)/g, '') // remove text in parentheses .replace(/[^a-z0-9\s-]/g, '') // remove special characters except spaces and hyphens .trim() .replace(/\s+/g, '-') // replace spaces with hyphens .replace(/-+/g, '-'); // collapse multiple hyphens } function openSelectionModal(group: string) { const data = MODAL_OPTIONS[group as keyof typeof MODAL_OPTIONS]; if (!data) return; currentModalGroup = group; modalTabsContainer.classList.add('hidden'); modalTitle.textContent = data.title; modalGrid.innerHTML = ''; modalSearchInput.value = ''; data.options.forEach(option => { const item = document.createElement('div'); item.className = 'modal-option-item'; item.dataset.optionName = option.name; const thumbnail = document.createElement('div'); thumbnail.className = 'modal-option-thumbnail'; const img = document.createElement('img'); img.alt = option.name; img.src = `/thumbnails/${sanitizeForFilename(group)}-${sanitizeForFilename(option.name)}.webp`; img.onerror = () => { img.remove(); const hash = option.name.split('').reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0); const h = hash % 360; thumbnail.style.background = `linear-gradient(45deg, hsl(${h}, 70%, 50%), hsl(${(h + 60) % 360}, 70%, 60%))`; }; thumbnail.appendChild(img); const label = document.createElement('span'); label.className = 'modal-option-label'; label.textContent = option.name; item.append(thumbnail, label); modalGrid.appendChild(item); }); modalGrid.onclick = (e) => { const item = (e.target as HTMLElement).closest('.modal-option-item'); if (item) { selectOptionFromModal(item.dataset.optionName!); } }; selectionModal.classList.remove('hidden'); } function openProfileAngleModal(initialTab: string) { modalTitle.textContent = 'Select Profile Angle'; const tabs = { profileRotation: 'Rotation', profileTilt: 'Tilt' }; const tabsWrapper = modalTabsContainer.querySelector('.tabs')!; tabsWrapper.innerHTML = ''; const renderGrid = (group: keyof typeof tabs) => { currentModalGroup = group; // Set the group for selection logic modalGrid.innerHTML = ''; const data = MODAL_OPTIONS[group]; if (!data) return; data.options.forEach(option => { const item = document.createElement('div'); item.className = 'modal-option-item'; item.dataset.optionName = option.name; item.title = option.description; const thumbnail = document.createElement('div'); thumbnail.className = 'modal-option-thumbnail'; const img = document.createElement('img'); img.alt = option.name; img.src = `/thumbnails/${sanitizeForFilename(group)}-${sanitizeForFilename(option.name)}.webp`; img.onerror = () => { img.remove(); const hash = option.name.split('').reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0); const h = hash % 360; thumbnail.style.background = `linear-gradient(45deg, hsl(${h}, 70%, 50%), hsl(${(h + 60) % 360}, 70%, 60%))`; }; thumbnail.appendChild(img); const label = document.createElement('span'); label.className = 'modal-option-label'; label.textContent = option.name; item.append(thumbnail, label); modalGrid.appendChild(item); }); }; Object.entries(tabs).forEach(([key, name]) => { const tabButton = document.createElement('button'); tabButton.className = 'tab-button'; tabButton.textContent = name; tabButton.dataset.group = key; if (key === initialTab) { tabButton.classList.add('active'); } tabButton.onclick = () => { tabsWrapper.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); tabButton.classList.add('active'); renderGrid(key as keyof typeof tabs); }; tabsWrapper.appendChild(tabButton); }); modalGrid.onclick = (e) => { const item = (e.target as HTMLElement).closest('.modal-option-item'); if (item) { selectOptionFromModal(item.dataset.optionName!); } }; modalTabsContainer.classList.remove('hidden'); renderGrid(initialTab as keyof typeof tabs); // Initial render selectionModal.classList.remove('hidden'); } function closeSelectionModal() { selectionModal.classList.add('hidden'); // Important to remove the specific click listener to avoid memory leaks // and incorrect behavior if a different modal is opened later. modalGrid.onclick = null; } function selectOptionFromModal(option: string) { if (!currentModalGroup || !controlState[currentModalGroup]) return; const groupEl = document.querySelector(`.tag-group[data-group="${currentModalGroup}"]`)!; const container = groupEl.querySelector('.tag-container')!; const mode = container.dataset.mode; const stateSet = controlState[currentModalGroup] as Set; if (mode === 'single') { stateSet.clear(); stateSet.add(option); } else { if (!stateSet.has(option)) { stateSet.add(option); } } if (currentModalGroup === 'profileRotation' || currentModalGroup === 'profileTilt') { const optionData = MODAL_OPTIONS[currentModalGroup]?.options.find(opt => opt.name === option); if (optionData && typeof optionData.value === 'number') { const slider = currentModalGroup === 'profileRotation' ? xRotationSlider : yTiltSlider; slider.value = String(optionData.value); slider.dispatchEvent(new Event('input', { bubbles: true })); } } updateTagsUIFromState(); closeSelectionModal(); } function addTagToUI(group: string, value: string) { const groupEl = document.querySelector(`.tag-group[data-group="${group}"]`); if (!groupEl) return; const container = groupEl.querySelector('.tag-container') as HTMLElement; const addButton = container.querySelector('.add-tag-button'); const newTag = document.createElement('button'); newTag.className = 'tag-button active'; newTag.textContent = value; newTag.dataset.dynamic = 'true'; if (addButton) container.insertBefore(newTag, addButton); else container.appendChild(newTag); } // --- Alert Modal --- function setupAppAlertModal() { const closeModal = () => appAlertModal.classList.add('hidden'); appAlertCloseButton.addEventListener('click', closeModal); appAlertOkButton.addEventListener('click', closeModal); appAlertModal.addEventListener('click', e => { if (e.target === appAlertModal) closeModal(); }); } function showAppAlert(message: string, title = 'Notice') { appAlertMessage.textContent = message; (document.querySelector('#app-alert-title') as HTMLElement).textContent = title; appAlertModal.classList.remove('hidden'); } // --- Edit Modal Functions --- function setupEditModal() { maskCanvas = document.createElement('canvas'); maskCtx = maskCanvas.getContext('2d')!; editModalCloseButton.addEventListener('click', () => editImageModal.classList.add('hidden')); editPromptInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitEditButton.click(); } }); editToolbar.addEventListener('click', (e) => { const target = (e.target as HTMLElement).closest('.tool-button'); if (!target) return; const tool = target.dataset.tool!; if (tool === 'zoom_in') { cameraZoom *= 1.2; drawEditCanvas(); return; } if (tool === 'zoom_out') { cameraZoom /= 1.2; drawEditCanvas(); return; } currentEditTool = tool; editToolbar.querySelectorAll('.tool-button').forEach(b => b.classList.remove('active')); target.classList.add('active'); if (currentEditTool === 'pan') { editCanvasContainer.style.cursor = 'grab'; customBrushCursor.style.display = 'none'; } else { editCanvasContainer.style.cursor = 'none'; customBrushCursor.style.display = 'block'; } }); brushSizeSlider.addEventListener('input', (e) => { brushSize = parseInt((e.target as HTMLInputElement).value, 10); brushSizeValue.textContent = `${brushSize}`; updateCustomCursor(); }); editCanvasContainer.addEventListener('mousedown', startInteraction); editCanvasContainer.addEventListener('mouseup', stopInteraction); editCanvasContainer.addEventListener('mouseout', stopInteraction); editCanvasContainer.addEventListener('mousemove', moveInteraction); editCanvasContainer.addEventListener('wheel', (e) => { e.preventDefault(); const rect = editCanvas.getBoundingClientRect(); const zoomPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const zoomFactor = -e.deltaY * ZOOM_SENSITIVITY; const newZoom = Math.max(0.1, cameraZoom + zoomFactor); cameraOffset.x = (cameraOffset.x - (zoomPoint.x - editCanvas.width / 2) / cameraZoom) * (newZoom / cameraZoom) + (zoomPoint.x - editCanvas.width / 2) / newZoom; cameraOffset.y = (cameraOffset.y - (zoomPoint.y - editCanvas.height / 2) / cameraZoom) * (newZoom / cameraZoom) + (zoomPoint.y - editCanvas.height / 2) / newZoom; cameraZoom = newZoom; drawEditCanvas(); updateCustomCursor(); }); submitEditButton.addEventListener('click', handleSubmitEdit); } function updateCustomCursor(e?: MouseEvent) { if (e) { const rect = editCanvasContainer.getBoundingClientRect(); customBrushCursor.style.left = `${e.clientX - rect.left}px`; customBrushCursor.style.top = `${e.clientY - rect.top}px`; } const cursorSize = brushSize * cameraZoom; customBrushCursor.style.width = `${cursorSize}px`; customBrushCursor.style.height = `${cursorSize}px`; const color = currentEditTool === 'eraser' ? 'rgba(236, 72, 153, 0.4)' : 'rgba(139, 92, 246, 0.3)'; customBrushCursor.style.backgroundColor = color; } function openEditModal() { if (!outputPreviewImage.src || outputPreviewImage.src.endsWith(window.location.href)) { showStatusError("No image to edit."); return; } editImageModal.classList.remove('hidden'); editImageSource = new Image(); editImageSource.crossOrigin = "anonymous"; editImageSource.src = outputPreviewImage.src; editImageSource.onload = () => { const containerRect = editCanvasContainer.getBoundingClientRect(); editCanvas.width = containerRect.width; editCanvas.height = containerRect.height; maskCanvas.width = editImageSource!.naturalWidth; maskCanvas.height = editImageSource!.naturalHeight; maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); const scaleX = editCanvas.width / editImageSource!.naturalWidth; const scaleY = editCanvas.height / editImageSource!.naturalHeight; cameraZoom = Math.min(scaleX, scaleY) * 0.95; cameraOffset = { x: 0, y: 0 }; editPromptInput.value = ''; brushSizeSlider.value = '30'; brushSizeSlider.dispatchEvent(new Event('input')); currentEditTool = 'brush'; editToolbar.querySelectorAll('.tool-button').forEach(b => b.classList.remove('active')); editToolbar.querySelector('.tool-button[data-tool="brush"]')?.classList.add('active'); editCanvasContainer.style.cursor = 'none'; customBrushCursor.style.display = 'block'; drawEditCanvas(); }; editImageSource.onerror = () => { showStatusError("Could not load image for editing."); editImageModal.classList.add('hidden'); } } function drawEditCanvas() { if (!editImageSource) return; editCtx.save(); editCtx.clearRect(0, 0, editCanvas.width, editCanvas.height); editCtx.translate(editCanvas.width / 2, editCanvas.height / 2); editCtx.scale(cameraZoom, cameraZoom); editCtx.translate(cameraOffset.x, cameraOffset.y); editCtx.drawImage(editImageSource, -editImageSource.naturalWidth / 2, -editImageSource.naturalHeight / 2); editCtx.globalAlpha = 0.4; editCtx.drawImage(maskCanvas, -editImageSource.naturalWidth / 2, -editImageSource.naturalHeight / 2); editCtx.restore(); } function getMousePosOnImage(e: MouseEvent) { if (!editImageSource) return { x: 0, y: 0 }; const rect = editCanvas.getBoundingClientRect(); const canvasPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const transform = new DOMMatrix() .translate(editCanvas.width / 2, editCanvas.height / 2) .scale(cameraZoom, cameraZoom) .translate(cameraOffset.x, cameraOffset.y) .translate(-editImageSource.naturalWidth / 2, -editImageSource.naturalHeight / 2); const imagePos = transform.inverse().transformPoint(new DOMPoint(canvasPos.x, canvasPos.y)); return imagePos; } function startInteraction(e: MouseEvent) { if (e.button !== 0) return; lastMousePos = { x: e.clientX, y: e.clientY }; if (currentEditTool === 'pan') { isPanning = true; editCanvasContainer.style.cursor = 'grabbing'; } else { isDrawing = true; maskCtx.beginPath(); const imagePos = getMousePosOnImage(e); maskCtx.moveTo(imagePos.x, imagePos.y); draw(e); } } function stopInteraction() { if (isDrawing) { maskCtx.beginPath(); } isDrawing = false; isPanning = false; if (currentEditTool === 'pan') { editCanvasContainer.style.cursor = 'grab'; } } function moveInteraction(e: MouseEvent) { if (currentEditTool !== 'pan') { updateCustomCursor(e); } if (isPanning) { const dx = (e.clientX - lastMousePos.x); const dy = (e.clientY - lastMousePos.y); cameraOffset.x += dx / cameraZoom; cameraOffset.y += dy / cameraZoom; drawEditCanvas(); } if (isDrawing) { draw(e); } lastMousePos = { x: e.clientX, y: e.clientY }; } function draw(e: MouseEvent) { if (!isDrawing) return; const pos = getMousePosOnImage(e); maskCtx.lineWidth = brushSize; maskCtx.lineCap = 'round'; maskCtx.lineJoin = 'round'; if (currentEditTool === 'brush') { maskCtx.strokeStyle = 'rgba(139, 92, 246, 1)'; maskCtx.globalCompositeOperation = 'source-over'; } else { // eraser maskCtx.globalCompositeOperation = 'destination-out'; } maskCtx.lineTo(pos.x, pos.y); maskCtx.stroke(); requestAnimationFrame(drawEditCanvas); } async function handleSubmitEdit() { const editPrompt = editPromptInput.value.trim(); if (!editPrompt) { showStatusError("Please describe the changes you want to make."); return; } const maskDataUrl = maskCanvas.toDataURL('image/png'); const [, maskBase64] = maskDataUrl.split(','); if (maskBase64.length < 200) { showStatusError("Please draw a mask to indicate where to apply changes."); return; } const maskPart: Part = { inlineData: { data: maskBase64, mimeType: 'image/png' } }; const imageToEditSrc = outputPreviewImage.src; const [imgHeader, imgBase64] = imageToEditSrc.split(','); const originalImagePart: Part = { inlineData: { data: imgBase64, mimeType: imgHeader.match(/:(.*?);/)?.[1] || 'image/png' } }; const apiKey = process.env.API_KEY; if (!apiKey) { await openApiKeyDialog(); return; } editImageModal.classList.add('hidden'); statusEl.textContent = ''; loadingOverlay.classList.remove('hidden'); loadingTextEl.textContent = 'Applying edits...'; setControlsDisabled(true); const parts: Part[] = [originalImagePart, maskPart, { text: editPrompt }]; const ai = new GoogleGenAI({ apiKey }); try { const response = await ai.models.generateContent({ model: 'gemini-2.5-flash-image', contents: { parts: parts }, config: { responseModalities: [Modality.IMAGE], }, }); if (response.promptFeedback?.blockReason) { throw new Error(`Request was blocked: ${response.promptFeedback.blockReason}. ${response.promptFeedback.blockReasonMessage || ''}`.trim()); } const imagePart = response.candidates?.[0]?.content?.parts?.find(part => part.inlineData); if (imagePart?.inlineData) { const newImageSrc = `data:${imagePart.inlineData.mimeType};base64,${imagePart.inlineData.data}`; const editedImageIndex = currentGeneratedImages.findIndex(img => img.src === imageToEditSrc); if (editedImageIndex > -1) { currentGeneratedImages[editedImageIndex] = { src: newImageSrc }; await displayGeneratedImages(currentGeneratedImages, editedImageIndex); } else { await displayGeneratedImages([{ src: newImageSrc }]); } statusEl.textContent = 'Image edited successfully.'; } else { const textResponse = response.text?.trim(); let errorMessage = 'The model did not generate an image from the edit.'; if (textResponse) { errorMessage += ` It might have only returned text: "${textResponse}"`; } throw new Error(errorMessage); } } catch (e) { console.error('Image edit failed:', e); showStatusError(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`); } finally { loadingOverlay.classList.add('hidden'); setControlsDisabled(false); } } // --- Zoom Modal Functions --- function setupZoomModal() { const closeModal = () => zoomModal.classList.add('hidden'); zoomModalCloseButton.addEventListener('click', closeModal); zoomModal.addEventListener('click', (e) => { if (e.target === zoomModal) { closeModal(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !zoomModal.classList.contains('hidden')) { closeModal(); } }); } // --- History Functions (IndexedDB) --- const DB_NAME = 'WowImageDB'; const DB_VERSION = 1; const STORE_NAME = 'images'; function initDB() { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = (event) => { console.error("Database error: ", (event.target as IDBRequest).error); showStatusError("Could not open history database. History will not be saved."); }; request.onsuccess = (event) => { db = (event.target as IDBRequest).result; }; request.onupgradeneeded = (event) => { const dbInstance = (event.target as IDBRequest).result; const objectStore = dbInstance.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); objectStore.createIndex("timestamp", "timestamp", { unique: false }); }; } async function dataUrlToBlob(dataUrl: string): Promise { const res = await fetch(dataUrl); return await res.blob(); } async function addToHistory(imageDataUrl: string) { if (!db) return; try { const blob = await dataUrlToBlob(imageDataUrl); const image: Omit = { blob, timestamp: Date.now() }; const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); const request = objectStore.add(image); return new Promise((resolve, reject) => { request.onsuccess = () => resolve(); request.onerror = (event) => { console.error("Error adding to history:", (event.target as IDBRequest).error); reject((event.target as IDBRequest).error); }; }); } catch (error) { console.error("Could not add image to history:", error); if (error instanceof DOMException && error.name === 'QuotaExceededError') { showAppAlert("History storage is full. Please delete some images."); } } } async function loadHistory(): Promise { return new Promise((resolve) => { if (!db) { resolve([]); return; } const transaction = db.transaction([STORE_NAME], 'readonly'); const objectStore = transaction.objectStore(STORE_NAME); const index = objectStore.index('timestamp'); const request = index.getAll(); request.onsuccess = (event) => { const images = (event.target as IDBRequest).result as HistoryImage[]; history.forEach(item => URL.revokeObjectURL(item.url)); const historyItems = images.reverse().map(img => ({ id: img.id, url: URL.createObjectURL(img.blob), })); resolve(historyItems); }; request.onerror = (event) => { console.error("Error loading history:", (event.target as IDBRequest).error); resolve([]); }; }); } async function removeFromHistory(id: number) { if (!db) return; const transaction = db.transaction([STORE_NAME], 'readwrite'); const objectStore = transaction.objectStore(STORE_NAME); const request = objectStore.delete(id); request.onsuccess = async () => { await renderHistory(); }; request.onerror = (event) => { console.error("Error deleting from history:", (event.target as IDBRequest).error); }; } async function useHistoryImage(imageDataUrl: string) { // Switch to image studio view first imageStudioLink.click(); await displayGeneratedImages([{src: imageDataUrl}]); outputGallery.classList.remove('hidden'); outputPlaceholder.style.display = 'none'; } async function renderHistory() { history = await loadHistory(); historyGridContainer.innerHTML = ''; if (history.length === 0) { historyEmptyState.classList.remove('hidden'); return; } historyEmptyState.classList.add('hidden'); history.forEach((item) => { const historyItem = document.createElement('div'); historyItem.className = 'history-item'; const img = document.createElement('img'); img.src = item.url; img.className = 'history-item-image'; img.loading = 'lazy'; const overlay = document.createElement('div'); overlay.className = 'history-item-overlay'; const useButton = document.createElement('button'); useButton.className = 'history-item-button'; useButton.innerHTML = ``; useButton.title = 'Use this image'; useButton.onclick = async (e) => { e.stopPropagation(); await useHistoryImage(item.url); }; const deleteButton = document.createElement('button'); deleteButton.className = 'history-item-button delete'; deleteButton.innerHTML = ``; deleteButton.title = 'Delete image'; deleteButton.onclick = async (e) => { e.stopPropagation(); await removeFromHistory(item.id); }; overlay.append(useButton, deleteButton); historyItem.append(img, overlay); historyGridContainer.appendChild(historyItem); }); window.lucide.createIcons(); } // --- Studio Functions --- function setupStudioSwitcher() { const links = [imageStudioLink, promptStudioLink, historyLink]; // The main containers for each view are now siblings in the grid const allViewContainers = [imageStudioView, promptStudioContainer, historyContainer]; const switchView = (activeLink: HTMLAnchorElement) => { links.forEach(link => link.classList.remove('active')); activeLink.classList.add('active'); // Hide all view containers and the output panel allViewContainers.forEach(container => container.classList.add('hidden')); outputPanel.classList.add('hidden'); // Show the correct containers for the active view if (activeLink === imageStudioLink) { imageStudioView.classList.remove('hidden'); outputPanel.classList.remove('hidden'); } else if (activeLink === promptStudioLink) { promptStudioContainer.classList.remove('hidden'); } else if (activeLink === historyLink) { historyContainer.classList.remove('hidden'); } }; imageStudioLink.addEventListener('click', (e) => { e.preventDefault(); switchView(imageStudioLink); }); promptStudioLink.addEventListener('click', (e) => { e.preventDefault(); switchView(promptStudioLink); }); historyLink.addEventListener('click', async (e) => { e.preventDefault(); await renderHistory(); switchView(historyLink); }); // Initialize with Image Studio view active switchView(imageStudioLink); } function handleI2PFile(file: File) { if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const dataUrl = event.target?.result as string; const [, base64Data] = dataUrl.split(','); const mimeType = file.type; i2pPreview.innerHTML = `Preview`; i2pPreview.classList.remove('hidden'); i2pDropZoneContent.classList.add('hidden'); generatePromptFromImage(base64Data, mimeType); }; reader.readAsDataURL(file); } function setupPromptStudio() { // Tab switching const i2pTab = document.querySelector('#tab-image-to-prompt') as HTMLButtonElement; const compTab = document.querySelector('#tab-composition') as HTMLButtonElement; const i2pContent = document.querySelector('#tab-content-image-to-prompt') as HTMLDivElement; const compContent = document.querySelector('#tab-content-composition') as HTMLDivElement; const i2pDropZone = document.querySelector('label[for="i2p-image-upload"]') as HTMLLabelElement; i2pTab.addEventListener('click', () => { i2pTab.classList.add('active'); compTab.classList.remove('active'); i2pContent.classList.remove('hidden'); compContent.classList.add('hidden'); }); compTab.addEventListener('click', () => { compTab.classList.add('active'); i2pTab.classList.remove('active'); compContent.classList.remove('hidden'); i2pContent.classList.add('hidden'); }); // Image to Prompt Logic i2pImageUpload.addEventListener('change', (e) => { const file = (e.target as HTMLInputElement).files?.[0]; handleI2PFile(file!); }); // Drag and drop for i2p ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { i2pDropZone.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false); }); ['dragenter', 'dragover'].forEach(eventName => { i2pDropZone.addEventListener(eventName, () => i2pDropZone.classList.add('drag-over'), false); }); ['dragleave', 'drop'].forEach(eventName => { i2pDropZone.addEventListener(eventName, () => i2pDropZone.classList.remove('drag-over'), false); }); i2pDropZone.addEventListener('drop', e => { const dt = e.dataTransfer; if (dt && dt.files[0]) { handleI2PFile(dt.files[0]); } }, false); i2pCopyButton.addEventListener('click', () => { navigator.clipboard.writeText(i2pPromptOutput.value).then(() => { showStatusError('Prompt copied!'); setTimeout(() => statusEl.innerHTML = '', 2000); }); }); i2pExpandButton.addEventListener('click', () => { fullPromptText.textContent = i2pPromptOutput.value; promptModal.classList.remove('hidden'); }); i2pGenerateButton.addEventListener('click', () => { promptEl.value = i2pPromptOutput.value; promptEl.dispatchEvent(new Event('input')); imageStudioLink.click(); setTimeout(() => generateButton.click(), 100); }); } async function generatePromptFromImage(base64Data: string, mimeType: string) { const apiKey = process.env.API_KEY; if (!apiKey) { openApiKeyDialog(); return; } i2pPromptOutput.value = 'Analyzing image...'; i2pPromptOutput.classList.remove('hidden'); i2pActions.classList.add('hidden'); try { const ai = new GoogleGenAI({ apiKey }); const imagePart = { inlineData: { data: base64Data, mimeType: mimeType } }; const textPart = { text: "Here's a detailed description of the image, followed by a rich, descriptive prompt for an AI image generator:\n\n---\n\n" }; const response = await ai.models.generateContent({ model: 'gemini-2.5-flash', contents: { parts: [imagePart, textPart] }, }); const newPrompt = response.text.trim(); if (newPrompt) { i2pPromptOutput.value = newPrompt; i2pActions.classList.remove('hidden'); } else { throw new Error('AI did not return a description.'); } } catch (e) { console.error('Image to Prompt failed:', e); i2pPromptOutput.value = `Error: Could not generate prompt. ${e instanceof Error ? e.message : ''}`; showStatusError('Error: Could not generate prompt.'); } } // --- God Mode & Head Visualizer --- function initHeadOrientationVisualizer(): THREE.Group { const container = headOrientationCanvas.parentElement!; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000); camera.position.z = 3; const renderer = new THREE.WebGLRenderer({ canvas: headOrientationCanvas, alpha: true, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(container.clientWidth, container.clientHeight); const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 1); directionalLight.position.set(2, 2, 5); scene.add(directionalLight); const group = new THREE.Group(); const material = new THREE.MeshStandardMaterial({ color: 0x8b5cf6, roughness: 0.6, metalness: 0.2 }); const headGeometry = new THREE.SphereGeometry(1, 32, 16); const headMesh = new THREE.Mesh(headGeometry, material); const noseGeometry = new THREE.ConeGeometry(0.3, 0.8, 32); const noseMesh = new THREE.Mesh(noseGeometry, material); noseMesh.position.z = 1; noseMesh.rotation.x = Math.PI / 2; group.add(headMesh); group.add(noseMesh); scene.add(group); // Set initial rotation group.rotation.y = (-controlState.xRotation * Math.PI) / 180; group.rotation.x = (-controlState.yTilt * Math.PI) / 180; function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); // Handle resizing new ResizeObserver(() => { camera.aspect = container.clientWidth / container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); }).observe(container); return group; } function setupGodMode() { const xRotValueEl = xRotationSlider.nextElementSibling as HTMLSpanElement; const yTiltValueEl = yTiltSlider.nextElementSibling as HTMLSpanElement; godModeToggle.addEventListener('click', () => { isGodModeActive = !isGodModeActive; godModeToggle.classList.toggle('active', isGodModeActive); godModeSliders.classList.toggle('hidden', !isGodModeActive); if (!isGodModeActive) { controlState.xRotation = 0; controlState.yTilt = 0; xRotationSlider.value = '0'; yTiltSlider.value = '0'; xRotationSlider.dispatchEvent(new Event('input', { bubbles: true })); yTiltSlider.dispatchEvent(new Event('input', { bubbles: true })); } renderPromptTags(); }); xRotationSlider.addEventListener('input', (e) => { const value = parseInt(xRotationSlider.value); controlState.xRotation = value; xRotValueEl.textContent = `${value}°`; if (headGroup) { headGroup.rotation.y = (-value * Math.PI) / 180; } if (e.isTrusted) { controlState.profileRotation.clear(); updateTagsUIFromState(); } else { renderPromptTags(); } }); yTiltSlider.addEventListener('input', (e) => { const value = parseInt(yTiltSlider.value); controlState.yTilt = value; yTiltValueEl.textContent = `${value}°`; if (headGroup) { headGroup.rotation.x = (-value * Math.PI) / 180; } if (e.isTrusted) { controlState.profileTilt.clear(); updateTagsUIFromState(); } else { renderPromptTags(); } }); } // --- Utility Functions --- function showStatusError(message: string) { statusEl.innerHTML = `${message}`; } function setControlsDisabled(disabled: boolean) { generateButton.disabled = disabled; resetButton.disabled = disabled; promptEl.disabled = disabled; imageUploadInput.disabled = disabled; allSliders.forEach((s) => (s.disabled = disabled)); document.querySelectorAll('.tag-button, .prompt-action-button, .remove-image-button, .tool-button, #submit-edit-button, .prompt-enhancer-button, .model-button').forEach(b => (b as HTMLButtonElement).disabled = disabled); [downloadButton, editButton, zoomButton, regenerateButton].forEach(b => b.disabled = disabled); }