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)} > - {`Thumbnail + {`Thumbnail ))} 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]