Eidos

Installation Notice

The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.

AI Image Editor

By: Mayne

Install Latest (v0.0.3)

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>
  )
}

Information

Author
Mayne
Type
block/fileHandler
Latest Version
0.0.3
Last Updated
11/21/2025
Published
11/21/2025

Version History

  • v0.0.3 11/21/2025
  • v0.0.2 11/21/2025
  • v0.0.1 11/21/2025