The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.
By: Mayne
Edit and transform images with AI-powered tools
import React, { useEffect, useState, useRef } from "react"
import { encode } from "@jsquash/webp"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { GripVertical, ImageIcon, Wand2, Columns, Split, Save, Upload } from "lucide-react"
// Image Editor Meta Configuration
export const meta = {
type: "fileHandler",
componentName: "ImageEditor",
fileHandler: {
title: "AI Image Editor",
name: "AI Image Editor",
description: "Edit and transform images with AI-powered tools",
extensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"],
icon: "🎨",
},
}
function useFilePathFromHash() {
const [filePath, setFilePath] = useState("")
useEffect(() => {
const updatePath = () => {
const hash = window.location.hash
const path = hash.startsWith("#") ? hash.substring(1) : hash
setFilePath(decodeURIComponent(path))
}
updatePath()
window.addEventListener("hashchange", updatePath)
return () => window.removeEventListener("hashchange", updatePath)
}, [])
return filePath
}
export default function ImageEditor() {
const [originalImage, setOriginalImage] = useState(null)
const [editedImage, setEditedImage] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [originalSize, setOriginalSize] = useState(0)
const [editedSize, setEditedSize] = useState(0)
const [compressionRatio, setCompressionRatio] = useState(0)
const [dividerPosition, setDividerPosition] = useState(50)
const [isDragging, setIsDragging] = useState(false)
const [editPrompt, setEditPrompt] = useState("Transform this image into anime/manga style with vibrant colors and stylized features")
const [selectedModel, setSelectedModel] = useState("google/gemini-2.5-flash-image") // Default to cheaper model
const [viewMode, setViewMode] = useState("compare") // "compare" or "side-by-side"
const [currentStep, setCurrentStep] = useState(0) // 0: idle, 1: compressing, 2: generating
const filePath = useFilePathFromHash()
const compareContainerRef = useRef(null)
const presetPrompts = [
{ value: "anime", label: "Anime Style", prompt: "Transform this image into anime/manga style with vibrant colors and stylized features" },
{ value: "watercolor", label: "Watercolor", prompt: "Convert this image to watercolor painting style with soft brush strokes and artistic texture" },
{ value: "oil", label: "Oil Painting", prompt: "Transform this image into oil painting style with rich textures and classic artistic feel" },
{ value: "sketch", label: "Pencil Sketch", prompt: "Convert this image to black and white pencil sketch style with detailed line work" },
{ value: "vintage", label: "Vintage", prompt: "Apply vintage film photography style with warm tones and nostalgic atmosphere" },
{ value: "cyberpunk", label: "Cyberpunk", prompt: "Transform into cyberpunk style with neon lights, futuristic elements and high contrast" },
{ value: "portrait", label: "Portrait Enhancement", prompt: "Enhance this portrait with professional photography style, better lighting and skin smoothing" },
{ value: "landscape", label: "Landscape Enhancement", prompt: "Enhance this landscape photo with more vibrant colors, better contrast and dramatic sky" }
]
const modelOptions = [
{
value: "google/gemini-2.5-flash-image",
label: "Gemini 2.5 Flash",
description: "Fast & Cheap - Good for quick edits and testing",
color: "text-green-600"
},
{
value: "google/gemini-3-pro-image-preview",
label: "Gemini 3 Pro",
description: "High Quality & Expensive - Best for final results",
color: "text-blue-600"
}
]
useEffect(() => {
const loadImage = async () => {
if (!filePath) return
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.tiff', '.svg']
const fileExt = filePath.toLowerCase().substring(filePath.lastIndexOf('.'))
if (!imageExtensions.includes(fileExt)) {
setError("This file is not an image format. Please select an image file.")
setOriginalImage(null)
setEditedImage(null)
setOriginalSize(0)
setEditedSize(0)
setCompressionRatio(0)
return
}
try {
setOriginalImage(null)
setEditedImage(null)
setOriginalSize(0)
setEditedSize(0)
setCompressionRatio(0)
setError("")
setIsLoading(true)
const fileData = await eidos.currentSpace.fs.readFile(filePath)
const url = '/' + filePath
const img = new Image()
img.onload = () => {
setOriginalImage(img)
setOriginalSize(new Blob([fileData]).size)
setIsLoading(false)
}
img.onerror = () => {
setError("Failed to load image. File may be corrupted or unsupported.")
setIsLoading(false)
}
img.src = url
} catch (err) {
console.error("Error loading image:", err)
setError(err.message || "Failed to load image")
setIsLoading(false)
}
}
loadImage()
}, [filePath])
const handleAIEdit = async () => {
if (!originalImage || !editPrompt.trim()) return
try {
setIsLoading(true)
setError("")
setCurrentStep(1) // Step 1: Compressing
// Check for API key
if (!process.env.OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY not found in environment variables. Please set it in script settings.")
}
// First, compress the image using existing handleCompress logic
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// Use smaller dimensions for AI processing (max 1024px)
let width = originalImage.width
let height = originalImage.height
const maxDimension = 1024
if (width > maxDimension || height > maxDimension) {
const ratio = Math.min(maxDimension / width, maxDimension / height)
width = Math.floor(width * ratio)
height = Math.floor(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(originalImage, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height)
// Use lower quality for AI processing to reduce tokens
const webpBuffer = await encode(imageData, { quality: 30 })
// Convert to base64 for API
const base64Image = btoa(String.fromCharCode(...new Uint8Array(webpBuffer)))
setCurrentStep(2) // Step 2: Generating
// Call OpenRouter API with selected model
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.href,
'X-Title': 'Eidos Image Editor'
},
body: JSON.stringify({
model: selectedModel, // Use selected model
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: editPrompt
},
{
type: 'image_url',
image_url: {
url: `data:image/webp;base64,${base64Image}`
}
}
]
}
],
modalities: ['image', 'text'],
})
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.choices && result.choices[0] && result.choices[0].message) {
const message = result.choices[0].message
if (message.images && message.images.length > 0) {
// Process generated image
const generatedImage = message.images[0]
const imageUrl = generatedImage.image_url.url
// Extract base64 data
const base64Data = imageUrl.split(',')[1]
const imageData = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0))
// Create edited image
const editedBlob = new Blob([imageData], { type: 'image/png' })
const editedUrl = URL.createObjectURL(editedBlob)
const editedImg = new Image()
editedImg.onload = () => {
setEditedImage(editedImg)
setEditedSize(imageData.byteLength)
const ratio = originalSize > 0 ? ((originalSize - imageData.byteLength) / originalSize * 100) : 0
setCompressionRatio(parseFloat(ratio.toFixed(1)))
setIsLoading(false)
setCurrentStep(0) // Reset steps
}
editedImg.src = editedUrl
} else if (message.content) {
throw new Error(`API returned text instead of image: ${message.content}`)
} else {
throw new Error('No image generated from API')
}
} else {
throw new Error('No result returned from API')
}
} catch (err) {
console.error("Error editing image:", err)
setError(err.message || "Failed to edit image")
setIsLoading(false)
setCurrentStep(0) // Reset steps on error
}
}
const handleDownload = () => {
if (!editedImage) return
const link = document.createElement('a')
const suffix = 'edited'
link.download = `${suffix}_${filePath.split('/').pop().replace(/\.[^/.]+$/, "")}.webp`
link.href = editedImage.src
link.click()
}
const handleSave = async () => {
if (!editedImage) return
try {
setIsLoading(true)
const response = await fetch(editedImage.src)
const blob = await response.blob()
const arrayBuffer = await blob.arrayBuffer()
// Add timestamp to filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const suffix = `edited_${timestamp}`
const originalName = filePath.split('/').pop()
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, "")
const newFileName = `${nameWithoutExt}_${suffix}.webp`
const pathParts = filePath.split('/')
pathParts.pop()
const directoryPath = pathParts.join('/')
const fullPath = directoryPath ? `${directoryPath}/${newFileName}` : newFileName
await eidos.currentSpace.fs.writeFile(fullPath, new Uint8Array(arrayBuffer))
setError("")
alert(`Saved as ${newFileName}`)
} catch (err) {
console.error("Error saving image:", err)
setError(err.message || "Failed to save image")
} finally {
setIsLoading(false)
}
}
const handleMouseMove = (e) => {
if (!isDragging || !compareContainerRef.current) return
const rect = compareContainerRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = (x / rect.width) * 100
setDividerPosition(Math.min(100, Math.max(0, percentage)))
}
const handleMouseUp = () => {
setIsDragging(false)
}
const handleMouseDown = (e) => {
setIsDragging(true)
handleMouseMove(e)
}
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging])
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
if (error) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="text-destructive mb-2">Error</div>
<div className="text-sm text-muted-foreground">{error}</div>
</div>
</div>
)
}
return (
<div className="flex h-screen bg-background flex-col">
{/* Header - 更紧凑的工具栏 */}
<div className="h-12 px-4 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-sm font-semibold">AI Image Editor</h1>
<span className="text-xs text-muted-foreground">Transform images with AI</span>
</div>
<div className="flex items-center gap-2">
{/* Model Selector - 添加模型选择器 */}
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Model:</span>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="bg-background border rounded px-2 py-1 text-xs"
disabled={isLoading}
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Results Info - 仅显示大小 */}
{editedImage && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>Original: {formatFileSize(originalSize)}</span>
<span>Generated: {formatFileSize(editedSize)}</span>
</div>
)}
{/* Save & Export Buttons */}
{editedImage && (
<>
<Button
onClick={handleSave}
disabled={isLoading}
variant="ghost"
size="sm"
className="gap-2 h-7 px-2 text-xs"
title="Save the generated image to current directory with '_edited' suffix"
>
<Save className="w-3 h-3" />
Save
</Button>
<Button
onClick={handleDownload}
variant="ghost"
size="sm"
className="gap-2 h-7 px-2 text-xs"
title="Export the generated image to your local downloads folder"
>
<Upload className="w-3 h-3" />
Export
</Button>
</>
)}
<Button
onClick={() => setViewMode(viewMode === "compare" ? "side-by-side" : "compare")}
variant="ghost"
size="sm"
className="gap-2 h-7 px-2 text-xs"
>
{viewMode === "compare" ? (
<>
<Columns className="w-3 h-3" />
Side
</>
) : (
<>
<Split className="w-3 h-3" />
Compare
</>
)}
</Button>
</div>
</div>
{/* Content Area - Left and Right Layout */}
<div className="flex-1 flex">
{/* Left Side - Controls Panel */}
<div className="w-[380px] border-r bg-background flex flex-col">
{/* Controls Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Model Info - 添加模型信息提示 */}
<div className="space-y-3">
<Label className="text-sm font-medium">AI Model</Label>
<div className="p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">
{modelOptions.find(m => m.value === selectedModel)?.label}
</span>
<span className={`text-xs ${modelOptions.find(m => m.value === selectedModel)?.color}`}>
{selectedModel.includes('2.5-flash') ? '⚡ Fast' : '✨ Pro'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{modelOptions.find(m => m.value === selectedModel)?.description}
</p>
</div>
</div>
{/* Quick Styles */}
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Styles</Label>
<div className="grid grid-cols-2 gap-2">
{presetPrompts.map((preset) => (
<Button
key={preset.value}
variant="outline"
size="sm"
className="text-xs h-9 justify-start"
onClick={() => setEditPrompt(preset.prompt)}
>
{preset.label}
</Button>
))}
</div>
</div>
{/* Custom Prompt */}
<div className="space-y-3">
<Label className="text-sm font-medium">Custom Prompt</Label>
<Textarea
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
placeholder="Describe how you want to transform the image..."
className="min-h-[120px] text-sm"
/>
</div>
{/* Generate Button */}
<Button
onClick={handleAIEdit}
disabled={isLoading || !editPrompt.trim()}
className="w-full gap-2 h-10"
>
<Wand2 className="w-4 h-4" />
{isLoading ? (
currentStep === 1 ? "Compressing..." :
currentStep === 2 ? "Generating..." :
"Processing..."
) : `Generate with ${modelOptions.find(m => m.value === selectedModel)?.label.split(' ')[1]}`}
</Button>
</div>
</div>
{/* Right Side - Image Preview */}
<div className="flex-1 relative bg-muted/10">
{originalImage ? (
viewMode === "compare" ? (
<div
ref={compareContainerRef}
className="w-full h-full relative cursor-ew-resize select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
>
{/* Original Image */}
<div className="absolute inset-0 overflow-hidden">
<img
src={originalImage.src}
alt="Original"
className="w-full h-full object-contain"
/>
</div>
{/* Edited Image */}
{editedImage && (
<>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 0 0 ${dividerPosition}%)` }}
>
<img
src={editedImage.src}
alt="Edited"
className="w-full h-full object-contain"
/>
</div>
{/* Divider */}
<div
className="absolute top-0 bottom-0 w-px bg-primary/50 cursor-ew-resize"
style={{ left: `${dividerPosition}%`, transform: 'translateX(-50%)' }}
>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-background border-2 border-primary rounded-full flex items-center justify-center shadow-lg">
<GripVertical className="w-5 h-5 text-primary" />
</div>
</div>
{/* Labels */}
<div className="absolute top-4 left-4 bg-background/90 px-3 py-1.5 rounded-md text-sm font-medium shadow-sm">
Original
</div>
<div className="absolute top-4 right-4 bg-background/90 px-3 py-1.5 rounded-md text-sm font-medium shadow-sm">
AI Edited
</div>
</>
)}
{/* No edit state */}
{!editedImage && (
<div className="absolute top-4 left-4 bg-background/90 px-3 py-1.5 rounded-md text-sm font-medium shadow-sm">
Original
</div>
)}
</div>
) : (
// Side-by-side mode
<div className="flex h-full">
{/* Original */}
<div className="flex-1 relative overflow-hidden border-r">
<img
src={originalImage.src}
alt="Original"
className="w-full h-full object-contain"
/>
<div className="absolute top-4 left-4 bg-background/90 px-3 py-1.5 rounded-md text-sm font-medium shadow-sm">
Original
</div>
</div>
{/* Edited or placeholder */}
{editedImage ? (
<div className="flex-1 relative overflow-hidden">
<img
src={editedImage.src}
alt="Edited"
className="w-full h-full object-contain"
/>
<div className="absolute top-4 left-4 bg-background/90 px-3 py-1.5 rounded-md text-sm font-medium shadow-sm">
AI Edited
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center">
<Wand2 className="w-16 h-16 text-muted-foreground mx-auto mb-3" />
<div className="text-lg font-medium text-muted-foreground mb-1">Ready to Generate</div>
<div className="text-sm text-muted-foreground">Enter a prompt and click Generate</div>
</div>
</div>
)}
</div>
)
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<ImageIcon className="w-16 h-16 text-muted-foreground mx-auto mb-3" />
<div className="text-lg font-medium text-muted-foreground mb-1">Loading Image</div>
<div className="text-sm text-muted-foreground">Please wait...</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}