Sfoglia il codice sorgente

Updated photo viewing module to render ARW DNG and CR2 (#200)

* Added ARW CR2 and DNG support

Added ARW CR2 and DNG support

* Centralize RAW format definitions and fix filepath.Ext issue

Fixed two maintainability issues:

1. **Fixed potential panic in mediaserver.go**:
   - Replaced unsafe string indexing: filepath[strings.LastIndex(filepath, "."):]
   - Now uses filepath.Ext() which safely handles files without extensions
   - Prevents index out of bounds panic

2. **Centralized RAW format definitions**:

   Backend (Go):
   - Added metadata.RawImageFormats constant
   - Added metadata.IsRawImageFile() helper function
   - Updated metadata.go to use constant
   - Updated mediaserver.go to use helper function
   - Single source of truth: src/mod/filesystem/metadata/metadata.go

   Frontend (JavaScript):
   - Created src/web/Photo/constants.js with RAW_IMAGE_EXTENSIONS
   - Added isRawImage() helper function
   - Updated all files to use shared constant:
     * init.agi - module registration
     * photo.js - main photo viewer
     * backend/listFolder.js - folder listing
     * embedded/listNearbyImage.js - embedded viewer
   - Added script tags to index.html and embedded.html

Benefits:
- Single place to add new RAW formats in future
- Consistent behavior across frontend and backend
- No more scattered hardcoded extension lists
- Safer filepath handling (no panic on edge cases)

RAW formats remain: ARW, CR2, DNG, NEF, RAF, ORF

* Updated backend script

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 2 settimane fa
parent
commit
44fa50a2ff

+ 16 - 0
src/mod/filesystem/metadata/metadata.go

@@ -26,6 +26,15 @@ import (
 
 */
 
+// RAW image format extensions supported by the system
+var RawImageFormats = []string{".arw", ".cr2", ".dng", ".nef", ".raf", ".orf"}
+
+// IsRawImageFile checks if the given file path is a RAW image format
+func IsRawImageFile(filePath string) bool {
+	ext := strings.ToLower(filepath.Ext(filePath))
+	return utils.StringInArray(RawImageFormats, ext)
+}
+
 type RenderHandler struct {
 	renderingFiles  sync.Map
 	renderingFolder sync.Map
@@ -159,6 +168,13 @@ func (rh *RenderHandler) generateCache(fsh *filesystem.FileSystemHandler, cacheF
 		return img, err
 	}
 
+	//RAW image formats (Sony, Canon, Nikon, etc.)
+	if IsRawImageFile(rpath) {
+		img, err := generateThumbnailForRAW(fsh, cacheFolder, rpath, generateOnly)
+		rh.renderingFiles.Delete(rpath)
+		return img, err
+	}
+
 	//Video formats, extract from the 5 sec mark
 	vidFormats := []string{".mkv", ".mp4", ".webm", ".ogv", ".avi", ".rmvb"}
 	if utils.StringInArray(vidFormats, strings.ToLower(filepath.Ext(rpath))) {

+ 598 - 0
src/mod/filesystem/metadata/raw.go

@@ -0,0 +1,598 @@
+package metadata
+
+import (
+	"bytes"
+	"encoding/binary"
+	"errors"
+	"image"
+	"image/jpeg"
+	"path/filepath"
+	"strings"
+
+	"github.com/nfnt/resize"
+	"github.com/oliamb/cutter"
+	"imuslab.com/arozos/mod/filesystem"
+)
+
+// Generate thumbnail for RAW image files by extracting embedded JPEG
+func generateThumbnailForRAW(fsh *filesystem.FileSystemHandler, cacheFolder string, file string, generateOnly bool) (string, error) {
+	if fsh.RequireBuffer {
+		return "", errors.New("RAW thumbnail generation not supported for buffered file systems")
+	}
+
+	fshAbs := fsh.FileSystemAbstraction
+
+	// Check file size - skip files larger than 100MB to prevent memory issues
+	fileSize := fshAbs.GetFileSize(file)
+	if fileSize > (100 << 20) {
+		return "", errors.New("RAW file too large (>100MB)")
+	}
+
+	// Read the RAW file
+	rawData, err := fshAbs.ReadFile(file)
+	if err != nil {
+		return "", errors.New("failed to read RAW file: " + err.Error())
+	}
+
+	// Try to decode the RAW image
+	var img image.Image
+	var jpegData []byte
+
+	ext := filepath.Ext(file)
+	if strings.ToLower(ext) == ".dng" {
+		// For DNG files, try both marker scanning and TIFF IFD parsing
+		// Then use the largest JPEG found
+		jpegFromMarkers, err1 := extractLargestJPEG(rawData)
+		jpegFromTIFF, err2 := extractJPEGFromTIFF(rawData)
+
+		// Use whichever is largest
+		if err1 == nil && err2 == nil {
+			if len(jpegFromMarkers) > len(jpegFromTIFF) {
+				jpegData = jpegFromMarkers
+			} else {
+				jpegData = jpegFromTIFF
+			}
+		} else if err1 == nil {
+			jpegData = jpegFromMarkers
+		} else if err2 == nil {
+			jpegData = jpegFromTIFF
+		} else {
+			return "", errors.New("failed to extract any JPEG from DNG file: " + err1.Error() + "; " + err2.Error())
+		}
+
+		img, _, err = image.Decode(bytes.NewReader(jpegData))
+		if err != nil {
+			return "", errors.New("failed to decode DNG JPEG data: " + err.Error())
+		}
+	} else {
+		// For other RAW formats (ARW, CR2, NEF, RAF, ORF), only use marker scanning
+		jpegData, err = extractLargestJPEG(rawData)
+		if err != nil {
+			return "", errors.New("failed to extract thumbnail from RAW file: " + err.Error())
+		}
+		img, _, err = image.Decode(bytes.NewReader(jpegData))
+		if err != nil {
+			return "", errors.New("failed to decode extracted thumbnail: " + err.Error())
+		}
+	}
+
+	// Resize and crop to match standard thumbnail size (480x480 square)
+	// Strategy: Resize so the SMALLER dimension is 480, ensuring both dimensions >= 480
+	// Then crop to 480x480 from center (object-fit: cover behavior)
+	b := img.Bounds()
+	imgWidth := b.Max.X
+	imgHeight := b.Max.Y
+
+	var m image.Image
+	if imgWidth < imgHeight {
+		// Portrait or tall image: set width to 480, height will be larger
+		m = resize.Resize(480, 0, img, resize.Lanczos3)
+	} else if imgHeight < imgWidth {
+		// Landscape or wide image: set height to 480, width will be larger
+		m = resize.Resize(0, 480, img, resize.Lanczos3)
+	} else {
+		// Square image: resize to 480x480
+		m = resize.Resize(480, 480, img, resize.Lanczos3)
+	}
+
+	// Crop out the center to create square thumbnail
+	croppedImg, err := cutter.Crop(m, cutter.Config{
+		Width:  480,
+		Height: 480,
+		Mode:   cutter.Centered,
+	})
+	if err != nil {
+		return "", errors.New("failed to crop thumbnail: " + err.Error())
+	}
+
+	// Create the thumbnail file with the full filename + .jpg
+	// e.g., DSC02977.ARW.jpg
+	outputPath := cacheFolder + filepath.Base(file) + ".jpg"
+	out, err := fshAbs.Create(outputPath)
+	if err != nil {
+		return "", err
+	}
+	defer out.Close()
+
+	// Write JPEG thumbnail
+	err = jpeg.Encode(out, croppedImg, &jpeg.Options{Quality: 90})
+	if err != nil {
+		return "", err
+	}
+
+	if !generateOnly {
+		// Return the image as base64
+		ctx, err := getImageAsBase64(fsh, outputPath)
+		return ctx, err
+	}
+
+	return "", nil
+}
+
+// Extract largest embedded JPEG from RAW file
+// Most RAW files (ARW, CR2, DNG, NEF, etc.) are TIFF-based and contain embedded JPEG previews
+// This follows the approach used by dcraw.c for thumbnail extraction
+func extractLargestJPEG(data []byte) ([]byte, error) {
+	// JPEG markers
+	jpegSOI := []byte{0xFF, 0xD8} // Start of Image (SOI)
+	jpegEOI := []byte{0xFF, 0xD9} // End of Image (EOI)
+
+	// Find all embedded JPEGs
+	type jpegCandidate struct {
+		data   []byte
+		offset int
+		width  int
+		height int
+		pixels int
+	}
+	var candidates []jpegCandidate
+	searchStart := 0
+
+	for searchStart < len(data)-1 {
+		// Find JPEG start marker (0xFF 0xD8)
+		startIdx := bytes.Index(data[searchStart:], jpegSOI)
+		if startIdx == -1 {
+			break // No more JPEGs
+		}
+		startIdx += searchStart
+
+		// Find JPEG end marker (0xFF 0xD9)
+		// Search from after the SOI marker
+		searchEnd := startIdx + 2
+		endIdx := -1
+
+		for searchEnd < len(data)-1 {
+			if data[searchEnd] == jpegEOI[0] && data[searchEnd+1] == jpegEOI[1] {
+				endIdx = searchEnd + 2 // Include the EOI marker
+				break
+			}
+			searchEnd++
+		}
+
+		if endIdx == -1 || endIdx <= startIdx {
+			// No valid end marker found, try next SOI
+			searchStart = startIdx + 2
+			continue
+		}
+
+		// Extract the JPEG data
+		jpegData := data[startIdx:endIdx]
+
+		// Validate this is a real JPEG by decoding its header
+		// This also gives us the dimensions
+		cfg, format, err := image.DecodeConfig(bytes.NewReader(jpegData))
+		if err == nil && format == "jpeg" && cfg.Width > 0 && cfg.Height > 0 {
+			candidates = append(candidates, jpegCandidate{
+				data:   jpegData,
+				offset: startIdx,
+				width:  cfg.Width,
+				height: cfg.Height,
+				pixels: cfg.Width * cfg.Height,
+			})
+		}
+
+		// Continue searching from after this JPEG
+		searchStart = endIdx
+	}
+
+	if len(candidates) == 0 {
+		return nil, errors.New("no valid embedded JPEG found in RAW file")
+	}
+
+	// Find the largest JPEG by pixel count
+	// DNG files often have multiple JPEGs: small thumbnail, medium preview, large preview
+	largestIdx := 0
+	largestPixels := 0
+
+	for i, candidate := range candidates {
+		if candidate.pixels > largestPixels {
+			largestPixels = candidate.pixels
+			largestIdx = i
+		}
+	}
+
+	return candidates[largestIdx].data, nil
+}
+
+// Extract JPEG data from TIFF/DNG file with JPEG compression
+// This handles DNG files where the main image is JPEG-compressed within TIFF structure
+// Based on dcraw.c approach for parsing TIFF IFDs
+func extractJPEGFromTIFF(data []byte) ([]byte, error) {
+	if len(data) < 8 {
+		return nil, errors.New("file too small to be valid TIFF")
+	}
+
+	// Check TIFF byte order
+	var order binary.ByteOrder
+	if data[0] == 'I' && data[1] == 'I' {
+		order = binary.LittleEndian
+	} else if data[0] == 'M' && data[1] == 'M' {
+		order = binary.BigEndian
+	} else {
+		return nil, errors.New("invalid TIFF byte order marker")
+	}
+
+	// Verify TIFF magic number (42)
+	magic := order.Uint16(data[2:4])
+	if magic != 42 {
+		return nil, errors.New("invalid TIFF magic number")
+	}
+
+	// Get offset to first IFD
+	ifdOffset := order.Uint32(data[4:8])
+
+	// Parse all IFDs to find JPEG-compressed image data
+	// DNG files often have multiple IFDs, we want the largest JPEG
+	var candidates [][]byte
+	parseTIFFIFDChain(data, order, uint64(ifdOffset), &candidates)
+
+	if len(candidates) == 0 {
+		// No JPEG found - try to extract uncompressed thumbnail from IFD#0
+		// This handles DNG files with compression=1 (uncompressed)
+		return extractUncompressedThumbnail(data, order, uint64(ifdOffset))
+	}
+
+	// Return the largest JPEG by byte size
+	largestIdx := 0
+	largestSize := len(candidates[0])
+	for i, candidate := range candidates {
+		if len(candidate) > largestSize {
+			largestSize = len(candidate)
+			largestIdx = i
+		}
+	}
+
+	return candidates[largestIdx], nil
+}
+
+// Extract uncompressed thumbnail from TIFF (fallback for DNG with compression=1)
+func extractUncompressedThumbnail(data []byte, order binary.ByteOrder, ifdOffset uint64) ([]byte, error) {
+	if ifdOffset >= uint64(len(data))-2 {
+		return nil, errors.New("IFD offset out of bounds")
+	}
+
+	// Read IFD entries to find width, height, strip offset, strip byte count
+	numEntries := order.Uint16(data[ifdOffset : ifdOffset+2])
+	entryOffset := ifdOffset + 2
+
+	var width, height uint32
+	var stripOffset, stripByteCount uint64
+	var compression, photometric uint16
+
+	for i := 0; i < int(numEntries); i++ {
+		if entryOffset+12 > uint64(len(data)) {
+			break
+		}
+
+		tag := order.Uint16(data[entryOffset : entryOffset+2])
+		fieldType := order.Uint16(data[entryOffset+2 : entryOffset+4])
+		valueOffset := entryOffset + 8
+
+		switch tag {
+		case 0x0100: // ImageWidth
+			width = readIFDValue(data, order, fieldType, valueOffset)
+		case 0x0101: // ImageLength
+			height = readIFDValue(data, order, fieldType, valueOffset)
+		case 0x0103: // Compression
+			compression = uint16(readIFDValue(data, order, fieldType, valueOffset))
+		case 0x0106: // PhotometricInterpretation
+			photometric = uint16(readIFDValue(data, order, fieldType, valueOffset))
+		case 0x0111: // StripOffsets
+			stripOffset = uint64(readIFDValue(data, order, fieldType, valueOffset))
+		case 0x0117: // StripByteCounts
+			stripByteCount = uint64(readIFDValue(data, order, fieldType, valueOffset))
+		}
+
+		entryOffset += 12
+	}
+
+	// Validate we have the necessary data
+	if width == 0 || height == 0 || stripOffset == 0 || stripByteCount == 0 {
+		return nil, errors.New("incomplete TIFF metadata for uncompressed thumbnail")
+	}
+
+	// Only handle uncompressed RGB (compression=1, photometric=2)
+	if compression != 1 || photometric != 2 {
+		return nil, errors.New("unsupported compression or photometric interpretation")
+	}
+
+	// Extract the RGB strip data
+	if stripOffset+stripByteCount > uint64(len(data)) {
+		return nil, errors.New("strip data extends beyond file")
+	}
+
+	stripData := data[stripOffset : stripOffset+stripByteCount]
+
+	// Decode RGB strip to image.Image
+	img := image.NewRGBA(image.Rect(0, 0, int(width), int(height)))
+	expectedSize := int(width * height * 3)
+	if len(stripData) < expectedSize {
+		return nil, errors.New("strip data smaller than expected")
+	}
+
+	// Copy RGB data to RGBA image
+	for y := 0; y < int(height); y++ {
+		for x := 0; x < int(width); x++ {
+			srcIdx := (y*int(width) + x) * 3
+			dstIdx := (y*int(width) + x) * 4
+			img.Pix[dstIdx] = stripData[srcIdx]     // R
+			img.Pix[dstIdx+1] = stripData[srcIdx+1] // G
+			img.Pix[dstIdx+2] = stripData[srcIdx+2] // B
+			img.Pix[dstIdx+3] = 255                 // A
+		}
+	}
+
+	// Encode as JPEG
+	var buf bytes.Buffer
+	err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
+	if err != nil {
+		return nil, errors.New("failed to encode uncompressed thumbnail as JPEG: " + err.Error())
+	}
+
+	return buf.Bytes(), nil
+}
+
+// Parse TIFF IFD chain recursively to find all JPEG data
+// DNG files have multiple IFDs linked together and SubIFDs
+func parseTIFFIFDChain(data []byte, order binary.ByteOrder, ifdOffset uint64, candidates *[][]byte) {
+	if ifdOffset == 0 || ifdOffset >= uint64(len(data))-2 {
+		return
+	}
+
+	// Read number of directory entries
+	numEntries := order.Uint16(data[ifdOffset : ifdOffset+2])
+	entryOffset := ifdOffset + 2
+
+	var stripOffsets []uint64
+	var stripByteCounts []uint64
+	var compression uint16
+	var subIFDOffsets []uint64
+
+	// Read IFD entries
+	for i := 0; i < int(numEntries); i++ {
+		if entryOffset+12 > uint64(len(data)) {
+			break
+		}
+
+		tag := order.Uint16(data[entryOffset : entryOffset+2])
+		fieldType := order.Uint16(data[entryOffset+2 : entryOffset+4])
+		count := order.Uint32(data[entryOffset+4 : entryOffset+8])
+		valueOffset := entryOffset + 8
+
+		switch tag {
+		case 0x002E: // Tag 46 - Direct JPEG thumbnail (dcraw approach)
+			// Check if data at this offset starts with JPEG marker (0xFF 0xD8)
+			// Count is the length of the JPEG data
+			if count > 4 && count < uint32(len(data)) {
+				// If count > 4, the value offset points to the actual data
+				var jpegOffset uint64
+				if count*getSizeForType(fieldType) > 4 {
+					jpegOffset = uint64(order.Uint32(data[valueOffset : valueOffset+4]))
+				} else {
+					jpegOffset = valueOffset
+				}
+
+				if jpegOffset+uint64(count) <= uint64(len(data)) {
+					// Verify JPEG marker
+					if data[jpegOffset] == 0xFF && data[jpegOffset+1] == 0xD8 {
+						jpegData := data[jpegOffset : jpegOffset+uint64(count)]
+						*candidates = append(*candidates, jpegData)
+					}
+				}
+			}
+		case 0x0103: // Compression
+			compression = uint16(readIFDValue(data, order, fieldType, valueOffset))
+		case 0x0111: // StripOffsets
+			stripOffsets = readIFDArray(data, order, fieldType, count, valueOffset)
+		case 0x0117: // StripByteCounts
+			stripByteCounts = readIFDArray(data, order, fieldType, count, valueOffset)
+		case 0x014A: // SubIFD tag - points to child IFDs
+			subIFDOffsets = readIFDArray(data, order, fieldType, count, valueOffset)
+		case 0x0201: // JPEGInterchangeFormat - direct JPEG offset
+			jpegOffset := uint64(readIFDValue(data, order, fieldType, valueOffset))
+			// Also get JPEGInterchangeFormatLength (tag 0x0202)
+			// We'll handle this in the next iteration if present
+			if jpegOffset > 0 && jpegOffset < uint64(len(data)) {
+				// Look for the length in remaining entries
+				for j := i + 1; j < int(numEntries); j++ {
+					nextEntryOffset := ifdOffset + 2 + uint64(j*12)
+					if nextEntryOffset+12 > uint64(len(data)) {
+						break
+					}
+					nextTag := order.Uint16(data[nextEntryOffset : nextEntryOffset+2])
+					if nextTag == 0x0202 { // JPEGInterchangeFormatLength
+						nextFieldType := order.Uint16(data[nextEntryOffset+2 : nextEntryOffset+4])
+						nextValueOffset := nextEntryOffset + 8
+						jpegLength := uint64(readIFDValue(data, order, nextFieldType, nextValueOffset))
+						if jpegOffset+jpegLength <= uint64(len(data)) {
+							jpegData := data[jpegOffset : jpegOffset+jpegLength]
+							*candidates = append(*candidates, jpegData)
+						}
+						break
+					}
+				}
+			}
+		}
+
+		entryOffset += 12
+	}
+
+	// If this IFD has JPEG-compressed strips, extract them
+	if compression == 6 || compression == 7 {
+		if len(stripOffsets) > 0 && len(stripByteCounts) > 0 {
+			var jpegData bytes.Buffer
+			for i := 0; i < len(stripOffsets) && i < len(stripByteCounts); i++ {
+				offset := stripOffsets[i]
+				length := stripByteCounts[i]
+				if offset+length <= uint64(len(data)) {
+					jpegData.Write(data[offset : offset+length])
+				}
+			}
+			if jpegData.Len() > 0 {
+				// Validate JPEG data
+				jpegBytes := jpegData.Bytes()
+				if len(jpegBytes) >= 2 && jpegBytes[0] == 0xFF && jpegBytes[1] == 0xD8 {
+					*candidates = append(*candidates, jpegBytes)
+				}
+			}
+		}
+	}
+
+	// Process SubIFDs (child IFDs)
+	for _, subOffset := range subIFDOffsets {
+		parseTIFFIFDChain(data, order, subOffset, candidates)
+	}
+
+	// Get next IFD in chain (offset is after all entries)
+	nextIFDOffset := entryOffset
+	if nextIFDOffset+4 <= uint64(len(data)) {
+		nextIFD := order.Uint32(data[nextIFDOffset : nextIFDOffset+4])
+		if nextIFD > 0 {
+			parseTIFFIFDChain(data, order, uint64(nextIFD), candidates)
+		}
+	}
+}
+
+// Read a single IFD value
+func readIFDValue(data []byte, order binary.ByteOrder, fieldType uint16, offset uint64) uint32 {
+	if offset+4 > uint64(len(data)) {
+		return 0
+	}
+
+	switch fieldType {
+	case 3: // SHORT
+		return uint32(order.Uint16(data[offset : offset+2]))
+	case 4: // LONG
+		return order.Uint32(data[offset : offset+4])
+	default:
+		return order.Uint32(data[offset : offset+4])
+	}
+}
+
+// Read an array of IFD values
+func readIFDArray(data []byte, order binary.ByteOrder, fieldType uint16, count uint32, valueOffset uint64) []uint64 {
+	var result []uint64
+
+	// If count * size <= 4, values are stored inline
+	// Otherwise, valueOffset contains pointer to actual data
+	var dataOffset uint64
+	valueSize := uint32(4)
+	if fieldType == 3 {
+		valueSize = 2
+	}
+
+	if count*valueSize <= 4 {
+		dataOffset = valueOffset
+	} else {
+		if valueOffset+4 > uint64(len(data)) {
+			return result
+		}
+		dataOffset = uint64(order.Uint32(data[valueOffset : valueOffset+4]))
+	}
+
+	for i := uint32(0); i < count; i++ {
+		offset := dataOffset + uint64(i*valueSize)
+		if offset+uint64(valueSize) > uint64(len(data)) {
+			break
+		}
+
+		var value uint64
+		if fieldType == 3 { // SHORT
+			value = uint64(order.Uint16(data[offset : offset+2]))
+		} else { // LONG
+			value = uint64(order.Uint32(data[offset : offset+4]))
+		}
+		result = append(result, value)
+	}
+
+	return result
+}
+
+// Get size in bytes for TIFF field type
+// Based on TIFF spec and dcraw.c logic
+func getSizeForType(fieldType uint16) uint32 {
+	// Type sizes: BYTE=1, ASCII=1, SHORT=2, LONG=4, RATIONAL=8, etc.
+	// dcraw uses: "11124811248484"[type]-'0'
+	typeSizes := []uint32{0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8, 4}
+	if fieldType < uint16(len(typeSizes)) {
+		return typeSizes[fieldType]
+	}
+	return 1
+}
+
+// Render full-size RAW image as JPEG for media serving
+func RenderRAWImage(fsh *filesystem.FileSystemHandler, file string) ([]byte, error) {
+	if fsh.RequireBuffer {
+		return nil, errors.New("RAW image rendering not supported for buffered file systems")
+	}
+
+	fshAbs := fsh.FileSystemAbstraction
+
+	// Check file size - skip files larger than 100MB to prevent memory issues
+	fileSize := fshAbs.GetFileSize(file)
+	if fileSize > (100 << 20) {
+		return nil, errors.New("RAW file too large (>100MB)")
+	}
+
+	// Read the RAW file
+	rawData, err := fshAbs.ReadFile(file)
+	if err != nil {
+		return nil, errors.New("failed to read RAW file: " + err.Error())
+	}
+
+	ext := filepath.Ext(file)
+	if strings.ToLower(ext) == ".dng" {
+		// For DNG files, try both marker scanning and TIFF IFD parsing
+		// Then use the largest JPEG found
+		jpegFromMarkers, err1 := extractLargestJPEG(rawData)
+		jpegFromTIFF, err2 := extractJPEGFromTIFF(rawData)
+
+		// Use whichever is largest
+		var jpegData []byte
+		if err1 == nil && err2 == nil {
+			if len(jpegFromMarkers) > len(jpegFromTIFF) {
+				jpegData = jpegFromMarkers
+			} else {
+				jpegData = jpegFromTIFF
+			}
+		} else if err1 == nil {
+			jpegData = jpegFromMarkers
+		} else if err2 == nil {
+			jpegData = jpegFromTIFF
+		} else {
+			return nil, errors.New("failed to extract any JPEG from DNG file: " + err1.Error() + "; " + err2.Error())
+		}
+
+		return jpegData, nil
+	}
+
+	// For other RAW formats (ARW, CR2, NEF, RAF, ORF), use marker scanning
+	jpegData, err := extractLargestJPEG(rawData)
+	if err != nil {
+		return nil, errors.New("failed to extract image from RAW file: " + err.Error())
+	}
+
+	// Return the JPEG data directly without re-encoding
+	// This preserves the original JPEG quality and is much faster
+	return jpegData, nil
+}

+ 242 - 0
src/mod/filesystem/metadata/raw_test.go

@@ -0,0 +1,242 @@
+package metadata
+
+import (
+	"encoding/binary"
+	"fmt"
+	"os"
+	"testing"
+)
+
+// Test DNG file parsing
+func TestDNGParsing(t *testing.T) {
+	files := []string{
+		"../../../L1004220.DNG",
+		"../../../DSC00360.dng",
+		"../../../PXL_20221216_222438616.dng",
+	}
+
+	for _, filename := range files {
+		if _, err := os.Stat(filename); os.IsNotExist(err) {
+			t.Logf("Skipping %s (not found)", filename)
+			continue
+		}
+
+		t.Logf("\n========== Testing %s ==========", filename)
+		data, err := os.ReadFile(filename)
+		if err != nil {
+			t.Errorf("Failed to read %s: %v", filename, err)
+			continue
+		}
+
+		t.Logf("File size: %d bytes", len(data))
+
+		// Test marker scanning
+		jpegFromMarkers, err := extractLargestJPEG(data)
+		if err != nil {
+			t.Logf("Marker scan failed: %v", err)
+		} else {
+			t.Logf("Marker scan found JPEG: %d bytes", len(jpegFromMarkers))
+		}
+
+		// Test TIFF IFD parsing with debug output
+		debugParseTIFF(t, data)
+
+		// Test extractJPEGFromTIFF
+		jpegFromTIFF, err := extractJPEGFromTIFF(data)
+		if err != nil {
+			t.Logf("TIFF IFD extraction failed: %v", err)
+		} else {
+			t.Logf("TIFF IFD extraction found JPEG: %d bytes", len(jpegFromTIFF))
+		}
+	}
+}
+
+func debugParseTIFF(t *testing.T, data []byte) {
+	if len(data) < 8 {
+		t.Log("File too small to be TIFF")
+		return
+	}
+
+	// Check byte order
+	var order binary.ByteOrder
+	if data[0] == 'I' && data[1] == 'I' {
+		order = binary.LittleEndian
+		t.Log("Byte order: Little-endian (II)")
+	} else if data[0] == 'M' && data[1] == 'M' {
+		order = binary.BigEndian
+		t.Log("Byte order: Big-endian (MM)")
+	} else {
+		t.Logf("Invalid TIFF byte order: %02X %02X", data[0], data[1])
+		return
+	}
+
+	// Verify magic number
+	magic := order.Uint16(data[2:4])
+	if magic != 42 {
+		t.Logf("Invalid TIFF magic number: %d (expected 42)", magic)
+		return
+	}
+	t.Log("Magic number: 42 (valid TIFF)")
+
+	// Get first IFD offset
+	ifdOffset := order.Uint32(data[4:8])
+	t.Logf("First IFD offset: 0x%X (%d)", ifdOffset, ifdOffset)
+
+	// Parse IFD chain with debug output
+	ifdNum := 0
+	visited := make(map[uint32]bool)
+	for ifdOffset != 0 {
+		if visited[ifdOffset] {
+			t.Logf("IFD loop detected at offset 0x%X", ifdOffset)
+			break
+		}
+		visited[ifdOffset] = true
+
+		t.Logf("\n--- IFD #%d at offset 0x%X ---", ifdNum, ifdOffset)
+		nextIFD := debugParseIFD(t, data, order, uint64(ifdOffset), 0)
+		ifdOffset = uint32(nextIFD)
+		ifdNum++
+
+		if ifdNum > 20 {
+			t.Log("Too many IFDs, stopping")
+			break
+		}
+	}
+}
+
+func debugParseIFD(t *testing.T, data []byte, order binary.ByteOrder, ifdOffset uint64, depth int) uint64 {
+	if ifdOffset >= uint64(len(data))-2 {
+		t.Logf("IFD offset out of bounds: 0x%X", ifdOffset)
+		return 0
+	}
+
+	indent := ""
+	for i := 0; i < depth; i++ {
+		indent += "  "
+	}
+
+	numEntries := order.Uint16(data[ifdOffset : ifdOffset+2])
+	t.Logf("%sNumber of entries: %d", indent, numEntries)
+
+	if numEntries > 512 {
+		t.Logf("%sToo many entries, skipping", indent)
+		return 0
+	}
+
+	entryOffset := ifdOffset + 2
+
+	// Track important tags
+	var compression uint16
+	var width, height uint32
+	var subIFDs []uint64
+
+	for i := 0; i < int(numEntries); i++ {
+		if entryOffset+12 > uint64(len(data)) {
+			break
+		}
+
+		tag := order.Uint16(data[entryOffset : entryOffset+2])
+		fieldType := order.Uint16(data[entryOffset+2 : entryOffset+4])
+		count := order.Uint32(data[entryOffset+4 : entryOffset+8])
+		valueOffset := entryOffset + 8
+
+		// Get value
+		var value uint64
+		typeSize := getSizeForType(fieldType)
+		if count*typeSize <= 4 {
+			// Value is inline
+			if fieldType == 3 { // SHORT
+				value = uint64(order.Uint16(data[valueOffset : valueOffset+2]))
+			} else if fieldType == 4 { // LONG
+				value = uint64(order.Uint32(data[valueOffset : valueOffset+4]))
+			}
+		} else {
+			// Value is a pointer
+			value = uint64(order.Uint32(data[valueOffset : valueOffset+4]))
+		}
+
+		tagName := getTagName(tag)
+		t.Logf("%s  Tag 0x%04X (%s): type=%d count=%d value=0x%X", indent, tag, tagName, fieldType, count, value)
+
+		switch tag {
+		case 0x0100: // ImageWidth
+			width = uint32(value)
+		case 0x0101: // ImageLength
+			height = uint32(value)
+		case 0x0103: // Compression
+			compression = uint16(value)
+			t.Logf("%s    -> Compression type: %d", indent, compression)
+		case 0x002E: // Tag 46 - JPEG preview
+			t.Logf("%s    -> Tag 46 detected! JPEG at 0x%X, length %d", indent, value, count)
+		case 0x0111: // StripOffsets
+			t.Logf("%s    -> StripOffsets found", indent)
+		case 0x0117: // StripByteCounts
+			t.Logf("%s    -> StripByteCounts found", indent)
+		case 0x0201: // JPEGInterchangeFormat
+			t.Logf("%s    -> JPEGInterchangeFormat at 0x%X", indent, value)
+		case 0x0202: // JPEGInterchangeFormatLength
+			t.Logf("%s    -> JPEGInterchangeFormatLength: %d bytes", indent, value)
+		case 0x014A: // SubIFD
+			t.Logf("%s    -> SubIFD pointer found", indent)
+			// Read SubIFD offsets
+			if count*typeSize > 4 {
+				dataOffset := value
+				for j := uint32(0); j < count; j++ {
+					offset := dataOffset + uint64(j*4)
+					if offset+4 <= uint64(len(data)) {
+						subOffset := uint64(order.Uint32(data[offset : offset+4]))
+						subIFDs = append(subIFDs, subOffset)
+						t.Logf("%s      SubIFD[%d] at 0x%X", indent, j, subOffset)
+					}
+				}
+			}
+		}
+
+		entryOffset += 12
+	}
+
+	if width > 0 || height > 0 {
+		t.Logf("%sImage dimensions: %dx%d, compression=%d", indent, width, height, compression)
+	}
+
+	// Parse SubIFDs
+	for i, subOffset := range subIFDs {
+		t.Logf("%s\n%s--- SubIFD #%d at 0x%X ---", indent, indent, i, subOffset)
+		debugParseIFD(t, data, order, subOffset, depth+1)
+	}
+
+	// Get next IFD offset
+	nextIFDOffset := entryOffset
+	if nextIFDOffset+4 <= uint64(len(data)) {
+		nextIFD := order.Uint32(data[nextIFDOffset : nextIFDOffset+4])
+		if nextIFD > 0 {
+			t.Logf("%sNext IFD at 0x%X", indent, nextIFD)
+		}
+		return uint64(nextIFD)
+	}
+
+	return 0
+}
+
+func getTagName(tag uint16) string {
+	names := map[uint16]string{
+		0x002E: "JPEGPreview",
+		0x0100: "ImageWidth",
+		0x0101: "ImageLength",
+		0x0103: "Compression",
+		0x0111: "StripOffsets",
+		0x0117: "StripByteCounts",
+		0x014A: "SubIFD",
+		0x0201: "JPEGInterchangeFormat",
+		0x0202: "JPEGInterchangeFormatLength",
+		0x0106: "PhotometricInterpretation",
+		0x010E: "ImageDescription",
+		0x010F: "Make",
+		0x0110: "Model",
+		0x0112: "Orientation",
+	}
+	if name, ok := names[tag]; ok {
+		return name
+	}
+	return "Unknown"
+}

+ 23 - 0
src/mod/media/mediaserver/mediaserver.go

@@ -17,6 +17,7 @@ import (
 	"imuslab.com/arozos/mod/compatibility"
 	"imuslab.com/arozos/mod/filesystem"
 	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/filesystem/metadata"
 	"imuslab.com/arozos/mod/info/logger"
 	"imuslab.com/arozos/mod/media/transcoder"
 	"imuslab.com/arozos/mod/user"
@@ -141,6 +142,13 @@ func (s *Instance) ServeMediaMime(w http.ResponseWriter, r *http.Request) {
 		utils.SendErrorResponse(w, err.Error())
 		return
 	}
+
+	// RAW images are served as JPEG
+	if metadata.IsRawImageFile(realFilepath) {
+		utils.SendTextResponse(w, "image/jpeg")
+		return
+	}
+
 	targetFshAbs := targetFsh.FileSystemAbstraction
 	if targetFsh.RequireBuffer {
 		//File is not on local. Guess its mime by extension
@@ -172,6 +180,21 @@ func (s *Instance) ServerMedia(w http.ResponseWriter, r *http.Request) {
 
 	targetFshAbs := targetFsh.FileSystemAbstraction
 
+	// Check if this is a RAW image file and render it as JPEG
+	if metadata.IsRawImageFile(realFilepath) {
+		jpegData, err := metadata.RenderRAWImage(targetFsh, realFilepath)
+		if err != nil {
+			// If RAW rendering fails, fall back to serving the raw file
+			s.options.Logger.PrintAndLog("Media Server", "Failed to render RAW image: "+err.Error(), nil)
+		} else {
+			// Successfully rendered RAW image, serve as JPEG
+			w.Header().Set("Content-Type", "image/jpeg")
+			w.Header().Set("Content-Length", strconv.Itoa(len(jpegData)))
+			w.Write(jpegData)
+			return
+		}
+	}
+
 	//Check if downloadMode
 	downloadMode := false
 	dw, _ := utils.GetPara(r, "download")

+ 64 - 5
src/web/Photo/backend/listFolder.js

@@ -1,5 +1,6 @@
 requirelib("filelib")
 
+
 function getExt(filename){
     return filename.split(".").pop();
 }
@@ -7,12 +8,66 @@ function getExt(filename){
 function isImage(filename){
     var ext = getExt(filename);
     ext = ext.toLowerCase();
-    if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"){
+    if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" ||
+        isRawImage(filename)){
         return true;
     }
     return false;
 }
 
+function isRawImage(filename){
+    var ext = getExt(filename);
+    ext = ext.toLowerCase();
+    return (ext == "arw" || ext == "cr2" || ext == "dng" || ext == "nef" || ext == "raf" || ext == "orf");
+}
+
+function getBasename(filename){
+    var parts = filename.split("/");
+    var name = parts[parts.length - 1];
+    var nameParts = name.split(".");
+    nameParts.pop();
+    return nameParts.join(".");
+}
+
+function filterDuplicates(files){
+    // Create a map to store files by their basename
+    var fileMap = {};
+
+    for (var i = 0; i < files.length; i++){
+        var filepath = files[i];
+        var basename = getBasename(filepath);
+        var isRaw = isRawImage(filepath);
+
+        if (!fileMap[basename]){
+            fileMap[basename] = {
+                raw: null,
+                jpg: null
+            };
+        }
+
+        if (isRaw){
+            fileMap[basename].raw = filepath;
+        } else {
+            fileMap[basename].jpg = filepath;
+        }
+    }
+
+    // Build result array, prioritizing RAW over JPG
+    var result = [];
+    for (var basename in fileMap){
+        var entry = fileMap[basename];
+        if (entry.raw){
+            // If RAW exists, use it (ignore JPG)
+            result.push(entry.raw);
+        } else if (entry.jpg){
+            // Otherwise use JPG
+            result.push(entry.jpg);
+        }
+    }
+
+    return result;
+}
+
 function isHiddenFile(filepath){
     var filename = filepath.split("/").pop();
     if (filename.substring(0, 1) == "."){
@@ -42,7 +97,7 @@ function main(){
     if (typeof(sort) == "undefined"){
         sort = "smart";
     }
-    
+
     //Scan the folder
     var results = filelib.aglob(folder, sort);
 
@@ -55,14 +110,18 @@ function main(){
             if (!isHiddenFile(thisFile) && folderContainSubFiles(thisFile)){
                 folders.push(thisFile);
             }
-            
+
         }else{
             if (isImage(thisFile)){
                 files.push(thisFile);
             }
         }
     }
-    sendJSONResp(JSON.stringify([folders, files]));	
+
+    // Filter out JPG duplicates when RAW files exist
+    files = filterDuplicates(files);
+
+    sendJSONResp(JSON.stringify([folders, files]));
 }
 
-main();
+main();

+ 14 - 0
src/web/Photo/constants.js

@@ -0,0 +1,14 @@
+/*
+    Photo Module Constants
+    Shared constants across all Photo module scripts
+*/
+
+// RAW image format extensions supported by the Photo module
+// Must match backend RawImageFormats in src/mod/filesystem/metadata/metadata.go
+const RAW_IMAGE_EXTENSIONS = ['arw', 'cr2', 'dng', 'nef', 'raf', 'orf'];
+
+// Check if a file is a RAW image format
+function isRawImage(filename) {
+    var ext = filename.split('.').pop().toLowerCase();
+    return RAW_IMAGE_EXTENSIONS.includes(ext);
+}

+ 6 - 2
src/web/Photo/embedded.html

@@ -8,6 +8,7 @@
     <title>Photo Viewer</title>
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <script src="constants.js"></script>
     <link rel="manifest" href="manifest.json">
     <style>
         body{
@@ -286,11 +287,14 @@
 
         loadNearbyFiles(playbackFile.filepath);
 
-        function loadImage(filename, filepath){
+        async function loadImage(filename, filepath){
             $("#img").hide();
             ao_module_setWindowTitle("Photo - " + filename);
-            $("#img").attr("src", '../media?file=' + encodeURIComponent(filepath))
+
+            // Backend handles RAW files automatically
+            $("#img").attr("src", '../media?file=' + encodeURIComponent(filepath));
             currentImageURL = '../media?file=' + encodeURIComponent(filepath);
+
             currentImageFilename = filename;
             //realigin to center
             $('#img').on('load', function() {

+ 62 - 1
src/web/Photo/embedded/listNearbyImage.js

@@ -3,6 +3,63 @@ if (!loadedfile) {
     console.log("Failed to load lib filelib, terminated.");
 }
 
+function isRawImage(filename){
+    var ext = getExt(filename);
+    ext = ext.toLowerCase();
+    return (ext == "arw" || ext == "cr2" || ext == "dng" || ext == "nef" || ext == "raf" || ext == "orf");
+}
+
+function getExt(filename){
+    return filename.split(".").pop();
+}
+
+function getBasename(filename){
+    var parts = filename.split("/");
+    var name = parts[parts.length - 1];
+    var nameParts = name.split(".");
+    nameParts.pop();
+    return nameParts.join(".");
+}
+
+function filterDuplicates(files){
+    // Create a map to store files by their basename
+    var fileMap = {};
+
+    for (var i = 0; i < files.length; i++){
+        var filepath = files[i];
+        var basename = getBasename(filepath);
+        var isRaw = isRawImage(filepath);
+
+        if (!fileMap[basename]){
+            fileMap[basename] = {
+                raw: null,
+                jpg: null
+            };
+        }
+
+        if (isRaw){
+            fileMap[basename].raw = filepath;
+        } else {
+            fileMap[basename].jpg = filepath;
+        }
+    }
+
+    // Build result array, prioritizing RAW over JPG
+    var result = [];
+    for (var basename in fileMap){
+        var entry = fileMap[basename];
+        if (entry.raw){
+            // If RAW exists, use it (ignore JPG)
+            result.push(entry.raw);
+        } else if (entry.jpg){
+            // Otherwise use JPG
+            result.push(entry.jpg);
+        }
+    }
+
+    return result;
+}
+
 function listNearby(){
     var result = [];
     //Extract the path from the filepath
@@ -18,11 +75,15 @@ function listNearby(){
         //console.log(JSON.stringify(nearbyFiles[i]));
         var ext = thisFile.Ext.substr(1);
         ext = ext.toLowerCase();
-        if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "webp"){
+        if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "webp" ||
+            isRawImage(filename)){
             result.push(thisFile.Filepath);
         }
     }
 
+    // Filter out JPG duplicates when RAW files exist
+    result = filterDuplicates(result);
+
     sendJSONResp(JSON.stringify(result))
 }
 

+ 7 - 1
src/web/Photo/index.html

@@ -13,6 +13,7 @@
     <script src="../script/jquery.min.js"></script>
     <script src="../script/semantic/semantic.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <script src="constants.js"></script>
     <script src="histogram.js"></script>
     <script src="photo.js"></script>
     <style>
@@ -51,10 +52,14 @@
 
         .imagecard{
             border: 1px solid #444;
+            overflow: hidden;
         }
 
         .imagecard img{
             width: 100%;
+            height: 100%;
+            object-fit: cover;
+            object-position: center;
         }
 
         #viewbox{
@@ -415,7 +420,8 @@
                 <div x-show="viewMode === 'list'" class="ui relaxed divided inverted list">
                     <template x-for="image in images">
                         <div class="item" style="cursor: pointer; padding-left: 10px; " x-on:click="showImage($el); ShowModal();" :filedata="encodeURIComponent(JSON.stringify({'filename':image.split('/').pop(),'filepath':image}))">
-                            <img class="ui small image" :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image" style="width: 60px; height: 60px;">
+                            <img class="ui small image" :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image"
+                                 style="width: 60px; height: 60px;">
                             <div class="content">
                                 <div class="header" x-text="image.split('/').pop()"></div>
                                 <div class="description" x-text="image"></div>

+ 1 - 1
src/web/Photo/init.agi

@@ -16,7 +16,7 @@ var moduleLaunchInfo = {
 	LaunchEmb: "Photo/embedded.html",
 	InitFWSize: [900, 550],
 	InitEmbSize: [900, 500],
-	SupportedExt: [".jpg",".jpeg",".gif",".png"]
+	SupportedExt: [".jpg",".jpeg",".gif",".png",".webp", ".arw", ".cr2", ".dng", ".nef", ".raf", ".orf"]
 }
 
 //Register the module

+ 55 - 40
src/web/Photo/photo.js

@@ -11,6 +11,13 @@ let prePhoto = "";
 let nextPhoto = "";
 let currentModel = "";
 
+// Get viewable image URL (handles RAW files)
+function getViewableImageUrl(filepath, callback) {
+    // Both RAW and regular images now use backend rendering
+    const imageUrl = "../media?file=" + encodeURIComponent(filepath);
+    callback(imageUrl, true, false, isRawImage(filepath) ? 'backend_raw' : 'direct');
+}
+
 function scrollbarVisable(){
     return $("body")[0].scrollHeight > $("body").height();
 }
@@ -202,13 +209,14 @@ function closeViewer(){
     $('#photo-viewer').hide();
     window.location.hash = '';
     ao_module_setWindowTitle("Photo");
+
     setTimeout(function(){
         $("#fullImage").attr("src","img/loading.png");
         $("#bg-image").attr("src","");
         $("#info-filename").text("");
         $("#info-filepath").text("");
         $("#info-dimensions").text("Loading...");
-        
+
         // Reset EXIF data display
         $('#basic-info-section').hide();
         $('#shooting-params-section').hide();
@@ -218,7 +226,7 @@ function closeViewer(){
         $('#technical-params-section').hide();
         $('#no-exif-message').hide();
         $('.ui.divider').hide();
-        
+
         // Clear histogram canvas
         const canvas = document.getElementById('histogram-canvas');
         if (canvas) {
@@ -234,8 +242,9 @@ function showImage(object){
     if (typeof resetZoom === 'function') {
         resetZoom();
     }
-    
+
     var fd = JSON.parse(decodeURIComponent($(object).attr("filedata")));
+
     // Update image dimensions and generate histogram when loaded
     $("#fullImage").off("load").on('load', function() {
         let width = this.naturalWidth;
@@ -249,48 +258,54 @@ function showImage(object){
         }
     });
 
-    let imageUrl = "../media?file=" + fd.filepath;
-    $("#fullImage").attr('src', imageUrl);
-    $("#bg-image").attr('src', imageUrl);
-    $("#info-filename").text(fd.filename);
-    $("#info-filepath").text(fd.filepath);
-    
-    var nextCard = $(object).next();
-    var prevCard = $(object).prev();
-    if (nextCard.length > 0){
-        nextPhoto = nextCard[0];
-    }else{
-        nextPhoto = null;
-    }
+    // Get image URL (backend handles RAW files automatically)
+    getViewableImageUrl(fd.filepath, (imageUrl, isSupported, isBlob, method) => {
+        $("#fullImage").attr('src', imageUrl);
+        $("#bg-image").attr('src', imageUrl);
+        $("#info-filename").text(fd.filename);
+        $("#info-filepath").text(fd.filepath);
 
-    if (prevCard.length > 0){
-        prePhoto = prevCard[0];
-    }else{
-        prePhoto = null;
-    }
+        // Log the rendering method used
+        if (method === 'backend_raw') {
+            console.log('RAW file: Rendered by backend');
+        }
+
+        var nextCard = $(object).next();
+        var prevCard = $(object).prev();
+        if (nextCard.length > 0){
+            nextPhoto = nextCard[0];
+        }else{
+            nextPhoto = null;
+        }
 
+        if (prevCard.length > 0){
+            prePhoto = prevCard[0];
+        }else{
+            prePhoto = null;
+        }
 
-    ao_module_setWindowTitle("Photo - " + fd.filename);
+        ao_module_setWindowTitle("Photo - " + fd.filename);
 
-    window.location.hash = encodeURIComponent(JSON.stringify({filename: fd.filename, filepath: fd.filepath}));
+        window.location.hash = encodeURIComponent(JSON.stringify({filename: fd.filename, filepath: fd.filepath}));
 
-    // Check for EXIF data
-    fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getExif.js", {
-        method: 'POST',
-        cache: 'no-cache',
-        headers: {
-          'Content-Type': 'application/json'
-        },
-        body: JSON.stringify({
-            "filepath": fd.filepath
-        })
-    }).then(resp => {
-        resp.json().then(data => {
-            formatExifData(data, fd);
-        })
-    }).catch(error => {
-        console.error('Failed to fetch EXIF data:', error);
-        formatExifData({}, fd); // Call with empty EXIF to show tone analysis
+        // Check for EXIF data
+        fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getExif.js", {
+            method: 'POST',
+            cache: 'no-cache',
+            headers: {
+              'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({
+                "filepath": fd.filepath
+            })
+        }).then(resp => {
+            resp.json().then(data => {
+                formatExifData(data, fd);
+            })
+        }).catch(error => {
+            console.error('Failed to fetch EXIF data:', error);
+            formatExifData({}, fd); // Call with empty EXIF to show tone analysis
+        });
     });
 }