diff --git a/app.go b/app.go
index c1aa19b..91c4246 100644
--- a/app.go
+++ b/app.go
@@ -91,10 +91,11 @@ func (a *App) getCurrentImagePath() string {
}
type ExifResult struct {
- ImageURL string `json:"imageURL"`
- MimeType string `json:"mimeType"`
- Camera string `json:"camera"`
- Lens string `json:"lens"`
+ ImageURL string `json:"imageURL"`
+ ThumbURL string `json:"thumbURL"`
+ MimeType string `json:"mimeType"`
+ Camera string `json:"camera"`
+ Lens string `json:"lens"`
FocalLength string `json:"focalLength"`
Aperture string `json:"aperture"`
ShutterSpeed string `json:"shutterSpeed"`
@@ -210,6 +211,11 @@ func (a *App) ProcessPaths(paths []string) []ExifResult {
}
}
+ if len(validPaths) > maxImageTokens {
+ results = append(results, ExifResult{Error: fmt.Sprintf("%d枚を超える画像が選択されました。最初の%d枚のみ読み込みます。", maxImageTokens, maxImageTokens)})
+ validPaths = validPaths[:maxImageTokens]
+ }
+
// Process files concurrently with bounded parallelism.
// ProcessImageFile is thread-safe: doOpenImage uses a.mu for currentImagePath
// and registerImageToken uses imgMu for token management.
@@ -279,12 +285,15 @@ func (a *App) doOpenImage(filePath string, f *os.File, mimeType string) ExifResu
a.mu.Unlock()
var url string
+ var thumbUrl string
if a.handler != nil {
token := a.handler.registerImageToken(filePath)
url = fmt.Sprintf("/api/image?token=%s&t=%d", token, time.Now().UnixNano())
+ thumbUrl = fmt.Sprintf("/api/thumb?token=%s&t=%d", token, time.Now().UnixNano())
} else {
// Cache-busting timestamp ensures the browser fetches the new image.
url = fmt.Sprintf("/api/image?t=%d", time.Now().UnixNano())
+ thumbUrl = url // Fallback
}
var originalBPP float64
@@ -301,6 +310,7 @@ func (a *App) doOpenImage(filePath string, f *os.File, mimeType string) ExifResu
result := ExifResult{
ImageURL: url,
+ ThumbURL: thumbUrl,
MimeType: mimeType,
FilePath: filePath,
OriginalBPP: originalBPP,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 61ff8be..7cdf312 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -458,7 +458,7 @@ function App() {
className={`filmstrip-item ${imageManager.selectedIndex === idx ? 'selected' : ''}`}
onClick={() => imageManager.setSelectedIndex(idx)}
>
-
+
))}
diff --git a/frontend/src/hooks/useImageManager.ts b/frontend/src/hooks/useImageManager.ts
index 1b97cb5..4d90271 100644
--- a/frontend/src/hooks/useImageManager.ts
+++ b/frontend/src/hooks/useImageManager.ts
@@ -127,18 +127,22 @@ export function useImageManager(
const handleExifResults = useCallback((results: ExifResult[]) => {
const validResults = results.filter(r => !r.cancelled && !r.error && r.imageURL);
+
+ // Show the first error if any exists
+ const firstError = results.find(r => r.error);
+ if (firstError && firstError.error) {
+ console.error(firstError.error);
+ showToast(firstError.error, true);
+ }
+
if (validResults.length === 0) {
- const firstError = results.find(r => r.error);
- if (firstError && firstError.error) {
- console.error(firstError.error);
- showToast(firstError.error, true);
- }
return;
}
setImportedImages(validResults.map(r => ({
filePath: r.filePath || "",
imageURL: r.imageURL!,
+ thumbURL: r.thumbURL,
sourceMimeType: r.mimeType?.toLowerCase().includes('png') ? 'image/png' : 'image/jpeg',
originalBPP: r.originalBPP,
imageObj: null,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index a519cde..60573d7 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -54,6 +54,9 @@ export interface ImportedImage {
/** A local HTTP URL generated by the backend representing the image data. Used as the src for HTMLImageElements. */
imageURL: string;
+ /** A lightweight thumbnail URL. */
+ thumbURL?: string;
+
/** The MIME type of the original source image. */
sourceMimeType: 'image/jpeg' | 'image/png';
@@ -80,6 +83,7 @@ export interface ExifResult {
error?: string;
cancelled?: boolean;
imageURL?: string;
+ thumbURL?: string;
filePath?: string;
mimeType?: string;
camera?: string;
diff --git a/handler.go b/handler.go
index 9c556f8..9255f6a 100644
--- a/handler.go
+++ b/handler.go
@@ -1,9 +1,12 @@
package main
import (
+ "bytes"
"context"
"crypto/rand"
"encoding/hex"
+ "image"
+ "image/jpeg"
"io"
"log"
"mime"
@@ -15,8 +18,20 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/rwcarlsen/goexif/exif"
+ "golang.org/x/image/draw"
)
+const maxImageTokens = 2000
+
+// fileOpenSem restricts the number of concurrent file opens for /api/image and /api/thumb
+// to avoid hitting the OS file descriptor limit (e.g. 256 on macOS).
+var fileOpenSem = make(chan struct{}, 100)
+
+// thumbProcessSem restricts concurrent heavy image decoding/resizing.
+var thumbProcessSem = make(chan struct{}, 4)
+
// ImageHandler provides HTTP endpoints to stream images and receive binary save data,
// avoiding the memory-intensive Base64 IPC transfer.
// It is registered as AssetServer Middleware (not Handler) so that it intercepts
@@ -68,6 +83,8 @@ func (h *ImageHandler) Middleware(next http.Handler) http.Handler {
switch r.URL.Path {
case "/api/image":
h.handleImage(w, r)
+ case "/api/thumb":
+ h.handleThumb(w, r)
case "/api/save":
h.handleSave(w, r)
default:
@@ -101,11 +118,107 @@ func (h *ImageHandler) handleImage(w http.ResponseWriter, r *http.Request) {
return
}
+ fileOpenSem <- struct{}{}
+ defer func() { <-fileOpenSem }()
+
// http.ServeFile handles Content-Type detection, Range requests, and streaming
// without loading the entire file into memory.
http.ServeFile(w, r, filePath)
}
+// handleThumb serves a lightweight thumbnail for the requested image token.
+func (h *ImageHandler) handleThumb(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ token := r.URL.Query().Get("token")
+ h.imgMu.RLock()
+ filePath := h.imageTokens[token]
+ h.imgMu.RUnlock()
+
+ if filePath == "" {
+ http.Error(w, "Token not found", http.StatusNotFound)
+ return
+ }
+
+ fileOpenSem <- struct{}{}
+ defer func() { <-fileOpenSem }()
+
+ f, err := os.Open(filePath)
+ if err != nil {
+ http.Error(w, "File not found", http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+
+ // 1. Try to get EXIF thumbnail
+ x, err := exif.Decode(f)
+ if err == nil {
+ pic, err := x.JpegThumbnail()
+ if err == nil && len(pic) > 0 {
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Write(pic)
+ return
+ }
+ }
+
+ // 2. No EXIF thumbnail, generate one on the fly safely
+ thumbProcessSem <- struct{}{}
+ defer func() { <-thumbProcessSem }()
+
+ // Reset file pointer
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ http.Error(w, "Failed to seek file", http.StatusInternalServerError)
+ return
+ }
+
+ // Decode high-res image
+ img, _, err := image.Decode(f)
+ if err != nil {
+ http.Error(w, "Failed to decode image", http.StatusInternalServerError)
+ return
+ }
+
+ // Calculate thumbnail size (max 256x256)
+ bounds := img.Bounds()
+ w0, h0 := bounds.Dx(), bounds.Dy()
+ if w0 <= 0 || h0 <= 0 {
+ http.Error(w, "Invalid image dimensions", http.StatusInternalServerError)
+ return
+ }
+
+ var w1, h1 int
+ if w0 > h0 {
+ w1 = 256
+ h1 = h0 * 256 / w0
+ } else {
+ h1 = 256
+ w1 = w0 * 256 / h0
+ }
+ if w1 == 0 {
+ w1 = 1
+ }
+ if h1 == 0 {
+ h1 = 1
+ }
+
+ dst := image.NewRGBA(image.Rect(0, 0, w1, h1))
+ // ApproxBiLinear is faster than CatmullRom and sufficient for a thumbnail
+ draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, bounds, draw.Src, nil)
+
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 70}); err != nil {
+ log.Printf("Failed to encode generated thumbnail: %v", err)
+ http.Error(w, "Failed to encode thumbnail", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Write(buf.Bytes())
+}
+
// prepareSave is called from the IPC side (App.SaveImage) after the native save
// dialog completes. It generates a unique token, stores the save metadata, and
// returns the token. The frontend must include this token in the POST to /api/save.
@@ -155,8 +268,8 @@ func (h *ImageHandler) registerImageToken(filePath string) string {
token := generateToken()
- // Optional: Limit size to prevent memory leaks if many images are opened
- if len(h.imageTokens) >= 100 {
+ // Limit size to prevent memory leaks if many images are opened
+ if len(h.imageTokens) >= maxImageTokens {
// Evict the oldest entry (FIFO with registration-time refresh) to free space.
if len(h.imageTokenOrder) > 0 {
oldestToken := h.imageTokenOrder[0]