From: Svjatoslav Agejenko Date: Thu, 23 Jul 2026 07:02:42 +0000 (+0300) Subject: Handle image orientation properly X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=e3e10c44b5d1e20acec0d0c986f573cad3ff3687;p=meviz.git Handle image orientation properly --- diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 38c3050..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,71 +0,0 @@ -# MeWiz - Media Wizard - -A Java/Maven project - a Swiss army knife of various tools for audio, video, and photo editing/conversion. - -## Project Structure - -``` -src/main/java/eu/svjatoslav/meviz/ -├── Main.java # Entry point, module dispatcher -├── Module.java # Interface for all modules -├── encoder/ # Media encoding/conversion module -├── htmlindexer/ # HTML gallery generator module -├── renamer/ # Batch file rename module -├── replace/ # Text replacement module -└── bomremove/ # BOM stripping module -``` - -## Build Commands - -```bash -# Clean build -mvn clean package --settings .mvn/custom-settings.xml - -# Build without custom settings (may fail for some dependencies) -mvn clean package - -# Run tests (if any) -mvn test -``` - -## Running - -```bash -# After building, run the JAR directly -java -cp target/meviz-*-jar-with-dependencies.jar eu.svjatoslav.meviz.Main - -# Or use the convenience script (requires installation) -meviz - -# Available commands: -# - index : Generate/update HTML image gallery -# - rename : Batch file rename -# - replace : Text replacement -# - encode : Media encoding/conversion -# - bomremove : Strip BOM from files -# - help : Show help -``` - -## Dependencies - -- Java 8+ -- Maven -- External tools (for full functionality): - - `lame` - MP3 encoding - - `vorbis-tools` - OGG audio - - `imagemagick` - Image processing - - `ffmpeg` - Video/audio conversion - -## Code Style - -- Standard Java conventions -- Package prefix: `eu.svjatoslav.meviz` -- Each module implements the `Module` interface with methods: - - `getModuleCommand()` - CLI command name - - `getDescription()` - Brief description - - `run(String[] args)` - Execute module - - `showCommandlineHelp()` - Display help - -## Installation - -See `install` script for system-wide installation (requires sudo). diff --git a/AGENTS.org b/AGENTS.org new file mode 100644 index 0000000..17f075f --- /dev/null +++ b/AGENTS.org @@ -0,0 +1,101 @@ +:PROPERTIES: +:ID: 17c10717-ba3c-4f13-a18b-6b134a0efa86 +:END: + +#+TITLE: AGENTS — MeWiz (Media Wizard) +#+SUBTITLE: Guidelines for AI coding agents working in this repository +#+LANGUAGE: en + +* Overview +:PROPERTIES: +:ID: 932415dc-bfcc-4bc7-8d1e-4b85916f35dd +:END: + +A Java/Maven project — a Swiss army knife of various tools for audio, +video, and photo editing/conversion. License: CC0 (see ~COPYING~). + +* Project Structure +:PROPERTIES: +:ID: 3b8b4982-c11d-464b-8a90-551e5982c9e8 +:END: + +#+begin_example +src/main/java/eu/svjatoslav/meviz/ +├── Main.java # Entry point, module dispatcher +├── Module.java # Interface for all modules +├── encoder/ # Media encoding/conversion module +├── htmlindexer/ # HTML gallery generator module +├── renamer/ # Batch file rename module +├── replace/ # Text replacement module +└── bomremove/ # BOM stripping module +#+end_example + +* Build Commands +:PROPERTIES: +:ID: 55eee295-0bfb-492f-8f80-0d6837c7b461 +:END: + +#+begin_src bash +# Clean build +mvn clean package --settings .mvn/custom-settings.xml + +# Build without custom settings (may fail for some dependencies) +mvn clean package + +# Run tests (if any) +mvn test +#+end_src + +* Running +:PROPERTIES: +:ID: 05296108-b9b8-4310-8cca-439ef4589a90 +:END: + +#+begin_src bash +# After building, run the JAR directly +java -cp target/meviz-*-jar-with-dependencies.jar eu.svjatoslav.meviz.Main + +# Or use the convenience script (requires installation) +meviz + +# Available commands: +# - index : Generate/update HTML image gallery +# - rename : Batch file rename +# - replace : Text replacement +# - encode : Media encoding/conversion +# - bomremove : Strip BOM from files +# - help : Show help +#+end_src + +* Dependencies +:PROPERTIES: +:ID: 69373a95-4125-4d54-a6b5-4e4c370a8ad1 +:END: + +- Java 8+ +- Maven +- External tools (for full functionality): + - ~lame~ — MP3 encoding + - ~vorbis-tools~ — OGG audio + - ~imagemagick~ — Image processing + - ~ffmpeg~ — Video/audio conversion + +* Code Style +:PROPERTIES: +:ID: 04108d3c-059f-4280-998e-28e470979ca1 +:END: + +- Standard Java conventions +- Package prefix: ~eu.svjatoslav.meviz~ +- Each module implements the ~Module~ interface with methods: + - ~getModuleCommand()~ — CLI command name + - ~getDescription()~ — Brief description + - ~run(String[] args)~ — Execute module + - ~showCommandlineHelp()~ — Display help + +* Installation +:PROPERTIES: +:ID: 174b241c-1054-450a-91f0-1b18eae83266 +:END: + +See the ~install~ script for system-wide installation (requires sudo). diff --git a/src/main/java/eu/svjatoslav/meviz/htmlindexer/Constants.java b/src/main/java/eu/svjatoslav/meviz/htmlindexer/Constants.java index 25f0826..ea32774 100755 --- a/src/main/java/eu/svjatoslav/meviz/htmlindexer/Constants.java +++ b/src/main/java/eu/svjatoslav/meviz/htmlindexer/Constants.java @@ -10,7 +10,7 @@ public class Constants { public static final String DEFAULT_GALLERY_TITLE = "Gallery"; - public static final String METADATA_FILE_NAME = "metadata_6.dat"; + public static final String METADATA_FILE_NAME = "metadata_7.dat"; public static final String THUMBNAILS_DIRECTORY_NAME = ".thumbnails"; @@ -36,6 +36,6 @@ public class Constants { public static final String[] SUPPORTED_VIDEO_EXTENSIONS = { "avi", "mp4", "mpeg", "mpg", "mkv", "flv", "ogv"}; - public static final String THUMBNAIL_VERSION = "2"; + public static final String THUMBNAIL_VERSION = "3"; } diff --git a/src/main/java/eu/svjatoslav/meviz/htmlindexer/Utils.java b/src/main/java/eu/svjatoslav/meviz/htmlindexer/Utils.java index fabbce9..78ad63a 100755 --- a/src/main/java/eu/svjatoslav/meviz/htmlindexer/Utils.java +++ b/src/main/java/eu/svjatoslav/meviz/htmlindexer/Utils.java @@ -17,7 +17,9 @@ import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.metadata.IIOMetadata; import javax.imageio.metadata.IIOMetadataNode; +import javax.imageio.stream.ImageInputStream; import java.awt.*; +import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.File; @@ -25,6 +27,7 @@ import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; +import java.util.Iterator; import java.util.zip.CRC32; import static org.openimaj.image.ImageUtilities.createBufferedImage; @@ -138,6 +141,195 @@ public class Utils { } } + /** + * Read EXIF orientation tag (0x0112) from a JPEG file using the JDK's + * built-in JPEG ImageIO metadata. Returns 1 when no usable orientation + * information is present. + * + * EXIF orientation values: + * 1 = normal, 2 = flip horizontal, + * 3 = 180°, 4 = flip vertical, + * 5 = transpose, 6 = 90° CW, 7 = transverse, 8 = 270° CW + */ + public static int getExifOrientation(final File file) { + try (ImageInputStream stream = ImageIO.createImageInputStream(file)) { + if (stream == null) + return 1; + + final Iterator readers = ImageIO.getImageReaders(stream); + if (!readers.hasNext()) + return 1; + + final ImageReader reader = readers.next(); + try { + reader.setInput(stream); + final IIOMetadata metadata = reader.getImageMetadata(0); + if (metadata == null) + return 1; + + final String format = metadata.getNativeMetadataFormatName(); + if (format == null || !format.toLowerCase().contains("jpeg")) + return 1; + + final IIOMetadataNode root = (IIOMetadataNode) metadata + .getAsTree(format); + // scan all marker segments for the EXIF one + final NodeList allMarkers = root + .getElementsByTagName("markerSequence"); + if (allMarkers.getLength() == 0) + return 1; + + final NodeList segments = ((IIOMetadataNode) allMarkers + .item(0)).getElementsByTagName("unknown"); + for (int i = 0; i < segments.getLength(); i++) { + final IIOMetadataNode segment = (IIOMetadataNode) segments + .item(i); + final String markerTag = segment.getAttribute("MarkerTag"); + // EXIF is APP1 (marker tag 225 = 0xE1) + if (!"225".equals(markerTag)) + continue; + + final Object userObject = segment.getUserObject(); + if (!(userObject instanceof byte[])) + continue; + + final byte[] exif = (byte[]) userObject; + final Integer orientation = parseExifOrientation(exif); + if (orientation != null) + return orientation; + } + } finally { + reader.dispose(); + } + } catch (final Exception e) { + System.out.println("Could not read EXIF orientation from " + + file + ": " + e.getMessage()); + } + return 1; + } + + /** + * Parse a raw EXIF APP1 segment and extract the orientation tag (0x0112). + * Handles both little-endian ("II") and big-endian ("MM") TIFF headers. + */ + private static Integer parseExifOrientation(final byte[] exif) { + // Minimum: "Exif\0\0" (6) + byte order (2) + 42 (2) + IFD offset (4) + if (exif == null || exif.length < 14) + return null; + + // Must start with "Exif\0\0" + if (exif[0] != 'E' || exif[1] != 'x' || exif[2] != 'i' + || exif[3] != 'f' || exif[4] != 0 || exif[5] != 0) + return null; + + final int tiffStart = 6; + final boolean littleEndian; + if (exif[tiffStart] == 'I' && exif[tiffStart + 1] == 'I') + littleEndian = true; + else if (exif[tiffStart] == 'M' && exif[tiffStart + 1] == 'M') + littleEndian = false; + else + return null; + + final int ifdOffset = readInt32(exif, tiffStart + 4, littleEndian); + final int ifdPos = tiffStart + ifdOffset; + if (ifdPos < 0 || ifdPos + 2 > exif.length) + return null; + + final int entryCount = readUint16(exif, ifdPos, littleEndian); + for (int i = 0; i < entryCount; i++) { + final int entryPos = ifdPos + 2 + i * 12; + if (entryPos + 12 > exif.length) + break; + + final int tag = readUint16(exif, entryPos, littleEndian); + if (tag == 0x0112) + return readUint16(exif, entryPos + 8, littleEndian); + } + return null; + } + + private static int readUint16(final byte[] data, final int offset, + final boolean littleEndian) { + if (littleEndian) + return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8); + return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF); + } + + private static int readInt32(final byte[] data, final int offset, + final boolean littleEndian) { + if (littleEndian) + return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8) + | ((data[offset + 2] & 0xFF) << 16) + | ((data[offset + 3] & 0xFF) << 24); + return ((data[offset] & 0xFF) << 24) + | ((data[offset + 1] & 0xFF) << 16) + | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF); + } + + /** + * Rotate and/or flip the image in memory according to EXIF orientation. + * Returns the original image unchanged for orientation 1 (or unknown). + * Never touches the file on disk. + */ + public static BufferedImage applyExifOrientation( + final BufferedImage image, final int orientation) { + if (orientation < 2 || orientation > 8) + return image; + + final int w = image.getWidth(); + final int h = image.getHeight(); + // orientations 5-8 swap width and height + final boolean swapDimensions = orientation >= 5; + final int newWidth = swapDimensions ? h : w; + final int newHeight = swapDimensions ? w : h; + + final AffineTransform transform = new AffineTransform(); + switch (orientation) { + case 2: // flip horizontal + transform.scale(-1, 1); + transform.translate(-w, 0); + break; + case 3: // 180° + transform.translate(w, h); + transform.rotate(Math.PI); + break; + case 4: // flip vertical + transform.scale(1, -1); + transform.translate(0, -h); + break; + case 5: // transpose (flip along top-left to bottom-right axis) + transform.rotate(Math.PI / 2); + transform.scale(1, -1); + break; + case 6: // 90° clockwise + transform.translate(h, 0); + transform.rotate(Math.PI / 2); + break; + case 7: // transverse (flip along top-right to bottom-left axis) + transform.translate(h, w); + transform.rotate(Math.PI / 2); + transform.scale(-1, 1); + break; + case 8: // 270° clockwise (90° counter-clockwise) + transform.translate(0, w); + transform.rotate(-Math.PI / 2); + break; + default: + return image; + } + + final BufferedImage oriented = new BufferedImage(newWidth, newHeight, + image.getType() == 0 ? BufferedImage.TYPE_INT_RGB + : image.getType()); + final Graphics2D g = oriented.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(image, transform, null); + g.dispose(); + return oriented; + } + public static File getLayoutIndexFile(final Layout layout, final File directoryToIndex) { diff --git a/src/main/java/eu/svjatoslav/meviz/htmlindexer/metadata/fileTypes/Picture.java b/src/main/java/eu/svjatoslav/meviz/htmlindexer/metadata/fileTypes/Picture.java index 9604211..cdedd92 100755 --- a/src/main/java/eu/svjatoslav/meviz/htmlindexer/metadata/fileTypes/Picture.java +++ b/src/main/java/eu/svjatoslav/meviz/htmlindexer/metadata/fileTypes/Picture.java @@ -125,10 +125,26 @@ public class Picture extends AbstractFile { } } + /** + * Load image with EXIF orientation applied in memory. The file on disk is + * never modified. GIFs skip rotation (handled separately, frames are + * rotated during thumbnail generation). + */ + private static BufferedImage getOrientedBufferedImage(final File file) + throws ImageFormatError, IOException { + BufferedImage image = getBufferedImage(file); + if (!"gif".equalsIgnoreCase( + FilePathParser.getFileExtension(file.getName()))) { + final int orientation = Utils.getExifOrientation(file); + image = Utils.applyExifOrientation(image, orientation); + } + return image; + } + private static void makeJpegThumbnail(File inputFile, File outputFile, java.awt.Dimension preferredTargetDimensions) throws IOException, ImageFormatError { - final BufferedImage inputImage = getBufferedImage(inputFile); + final BufferedImage inputImage = getOrientedBufferedImage(inputFile); final java.awt.Dimension sourceImageDimension = new java.awt.Dimension( inputImage.getWidth(), inputImage.getHeight()); @@ -165,6 +181,9 @@ public class Picture extends AbstractFile { private static void makeGifThumbnail( File inputFile, File outputFile, java.awt.Dimension preferredTargetDimensions) throws IOException { + + final int orientation = Utils.getExifOrientation(inputFile); + ImageIcon imageIcon = new ImageIcon(IOHelper.getFileContents(inputFile)); final java.awt.Dimension sourceImageDimension = new java.awt.Dimension( @@ -183,7 +202,8 @@ public class Picture extends AbstractFile { Utils.ImageFrame[] frames = Utils.readGIF(reader); for (Utils.ImageFrame frame : frames) { - Image scaleImage = scaleImage(frame.image, targetDimensions.width, targetDimensions.height); + BufferedImage orientedImage = Utils.applyExifOrientation(frame.image, orientation); + Image scaleImage = scaleImage(orientedImage, targetDimensions.width, targetDimensions.height); BufferedImage bImage = new BufferedImage( targetDimensions.width, targetDimensions.height, BufferedImage.TYPE_INT_ARGB); Graphics2D bGr = bImage.createGraphics(); @@ -253,7 +273,7 @@ public class Picture extends AbstractFile { @Override public void updateFileMetadata(final File parentDirectory) throws Exception { - final BufferedImage image = getBufferedImage(getFile(parentDirectory)); + final BufferedImage image = getOrientedBufferedImage(getFile(parentDirectory)); dimensions = new Dimension(image.getWidth(), image.getHeight()); }