Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ function App() {
className={`filmstrip-item ${imageManager.selectedIndex === idx ? 'selected' : ''}`}
onClick={() => imageManager.setSelectedIndex(idx)}
>
<img id={`thumb-${idx}`} src={img.imageURL} alt={`Thumbnail ${idx}`} loading="lazy" draggable={false} />
<img id={`thumb-${idx}`} src={img.thumbURL || img.imageURL} alt={`Thumbnail ${idx}`} loading="lazy" draggable={false} />
</button>
))}
</div>
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/hooks/useImageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -80,6 +83,7 @@ export interface ExifResult {
error?: string;
cancelled?: boolean;
imageURL?: string;
thumbURL?: string;
filePath?: string;
mimeType?: string;
camera?: string;
Expand Down
117 changes: 115 additions & 2 deletions handler.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package main

import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"image"
"image/jpeg"
"io"
"log"
"mime"
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

w.Write のエラーを検査してログ出力してください。

Line 162-164 では EXIF サムネイル書き込み時にエラーをログ出力していますが、こちらでは未処理です。ヘッダー送信後なので HTTP エラーは返せませんが、デバッグのためにログ出力を追加してください。

🔧 修正案
 	w.Header().Set("Content-Type", "image/jpeg")
-	w.Write(buf.Bytes())
+	if _, err := w.Write(buf.Bytes()); err != nil {
+		log.Printf("Failed to write generated thumbnail: %v", err)
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
w.Write(buf.Bytes())
w.Header().Set("Content-Type", "image/jpeg")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("Failed to write generated thumbnail: %v", err)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@handler.go` at line 221, The w.Write(buf.Bytes()) call on line 221 does not
check or log errors from the write operation, whereas similar error handling is
performed for EXIF thumbnail writes in the earlier section (lines 162-164).
Although HTTP errors cannot be returned after headers are already sent, you
should capture the error returned by w.Write and add logging to record any write
failures for debugging purposes, following the same pattern used in the EXIF
thumbnail error handling.

}

// 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.
Expand Down Expand Up @@ -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]
Expand Down