using OpenTK.Graphics.OpenGL4; using OpenTK.Mathematics; using OpenTK.Windowing.Common; using OpenTK.Windowing.Common.Input; using OpenTK.Windowing.Desktop; using OpenTK.Windowing.GraphicsLibraryFramework; // https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Image.html using SixLabors.Fonts; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing; using SixLabors.ImageSharp.Formats.Jpeg; using System; using System.Diagnostics; namespace SemiColinGames; public class FpsCounter { private readonly int[] frameTimes = new int[30]; private double fps = 0; private int idx = 0; public int Fps { get => (int) Math.Ceiling(fps); } public void Update() { var now = Environment.TickCount; // ms if (frameTimes[idx] != 0) { var timeElapsed = now - frameTimes[idx]; fps = 1000.0 * frameTimes.Length / timeElapsed; } frameTimes[idx] = now; idx = (idx + 1) % frameTimes.Length; } } public class CameraInfo { public static float AspectRatio = 6000f / 4000f; } public enum ToolStatus { Active, Done, Canceled } public class Transform { float activeScale; Vector2i activeOffset; Vector2i photoSize; public Transform(float scale, Vector2i offset, Vector2i photoSize) { activeScale = scale; activeOffset = offset; this.photoSize = photoSize; } public Vector2i ScreenToImageDelta(int x, int y) { return new((int) (x / activeScale), (int) (y / activeScale)); } public Vector2i ScreenToImage(int x, int y) { int rx = (int) ((x - activeOffset.X) / activeScale); int ry = (int) ((y - activeOffset.Y) / activeScale); rx = Math.Clamp(rx, 0, photoSize.X); ry = Math.Clamp(ry, 0, photoSize.Y); return new(rx, ry); } public Vector2i ScreenToImage(Vector2i position) { return ScreenToImage(position.X, position.Y); } public Vector2i ImageToScreen(int x, int y) { int rx = (int) ((x * activeScale) + activeOffset.X); int ry = (int) ((y * activeScale) + activeOffset.Y); return new(rx, ry); } public Vector2i ImageToScreen(Vector2i position) { return ImageToScreen(position.X, position.Y); } } public interface ITool { ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry); string Status(); } public class ViewTool : ITool { private bool dragging = false; public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) { Vector2i mousePosition = (Vector2i) mouse.Position; if (mouse.IsButtonPressed(MouseButton.Button1) && geometry.PhotoBox.ContainsInclusive(mousePosition)) { dragging = true; } if (!mouse.IsButtonDown(MouseButton.Button1)) { dragging = false; } if (dragging) { Vector2 delta = mouse.Delta; Vector2i imageDelta = transform.ScreenToImageDelta((int) delta.X, (int) delta.Y); photo.ViewOffset = Vector2i.Add(photo.ViewOffset, imageDelta); } return ToolStatus.Active; } public string Status() { return ""; } } public class CropTool : ITool { enum Mode { Sizing, Translating }; Photo photo; Vector2i mouseDragStart; Vector2i mouseDragEnd; Mode mode; string status = ""; public CropTool(Photo photo) { this.photo = photo; mouseDragStart = new(photo.CropRectangle.Left, photo.CropRectangle.Top); mouseDragEnd = new(photo.CropRectangle.Right, photo.CropRectangle.Bottom); } public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) { Vector2i mousePosition = (Vector2i) mouse.Position; Vector2i imagePosition = transform.ScreenToImage(mousePosition); List corners = Util.RectangleCorners(photo.CropRectangle); Vector2i? oppositeCorner = null; bool mouseNearHandle = false; for (int i = 0; i < 4; i++) { Vector2i corner = corners[i]; Vector2i handlePosition = transform.ImageToScreen(corner.X, corner.Y); if (Vector2i.Subtract(mousePosition, handlePosition).ManhattanLength < 10) { mouseNearHandle = true; oppositeCorner = corners[(i + 2) % 4]; break; } } bool mouseInRectangle = photo.CropRectangle.Contains(imagePosition.X, imagePosition.Y); if (mouse.IsButtonPressed(MouseButton.Button1)) { if (mouseNearHandle || !mouseInRectangle) { mode = Mode.Sizing; } else { mode = Mode.Translating; } } if (mouseNearHandle) { game.Cursor = MouseCursor.Hand; } else if (mouseInRectangle) { game.Cursor = MouseCursor.Default; } else { game.Cursor = MouseCursor.Crosshair; } if (mode == Mode.Sizing) { if (mouse.IsButtonPressed(MouseButton.Button1)) { mouseDragStart = oppositeCorner ?? imagePosition; } if (mouse.IsButtonDown(MouseButton.Button1)) { mouseDragEnd = imagePosition; } var (left, right, top, bottom) = GetCrop(); if (left != right && top != bottom) { photo.CropRectangle = Rectangle.FromLTRB(left, top, right, bottom); } else { photo.CropRectangle = Rectangle.Empty; } } else { if (mouse.IsButtonDown(MouseButton.Button1)) { Vector2 delta = mouse.Delta; Vector2i imageDelta = transform.ScreenToImageDelta((int) delta.X, (int) delta.Y); photo.CropRectangle.Offset(imageDelta.X, imageDelta.Y); if (photo.CropRectangle.Left < 0) { photo.CropRectangle.Offset(-photo.CropRectangle.Left, 0); } if (photo.CropRectangle.Right > photo.Size.X) { int overshoot = photo.CropRectangle.Right - photo.Size.X; photo.CropRectangle.Offset(-overshoot, 0); } if (photo.CropRectangle.Top < 0) { photo.CropRectangle.Offset(0, -photo.CropRectangle.Top); } if (photo.CropRectangle.Bottom > photo.Size.Y) { int overshoot = photo.CropRectangle.Bottom - photo.Size.Y; photo.CropRectangle.Offset(0, -overshoot); } } } Rectangle r = photo.CropRectangle; status = $"({r.Left}, {r.Top}, {r.Right}, {r.Bottom}) {r.Width}x{r.Height}"; if (input.IsKeyPressed(Keys.Enter)) { game.Cursor = MouseCursor.Default; if (photo.Rating < 1) { photo.Rating = 1; } photo.ViewOffset = new(photo.Size.X / 2 - Rectangle.Center(r).X, photo.Size.Y / 2 - Rectangle.Center(r).Y); return ToolStatus.Done; } if (input.IsKeyPressed(Keys.Escape)) { game.Cursor = MouseCursor.Default; photo.CropRectangle = Rectangle.Empty; return ToolStatus.Canceled; } return ToolStatus.Active; } // left, right, top, bottom (int, int, int, int) GetCrop() { // FIXME: allow for unconstrained crop, 1:1, etc. float aspectRatio = 1f * photo.Size.X / photo.Size.Y; Vector2i start = mouseDragStart; Vector2i end = mouseDragEnd; int width = Math.Abs(end.X - start.X); int height = Math.Abs(end.Y - start.Y); int heightChange = Math.Min(height, (int) (width / aspectRatio)); int widthChange = (int) (heightChange * aspectRatio); if (end.X < start.X) { widthChange *= -1; } if (end.Y < start.Y) { heightChange *= -1; } end.Y = start.Y + heightChange; end.X = start.X + widthChange; int left = Math.Min(start.X, end.X); int right = Math.Max(start.X, end.X); int top = Math.Min(start.Y, end.Y); int bottom = Math.Max(start.Y, end.Y); return (left, right, top, bottom); } public string Status() { return "[crop] " + status; } } public class StraightenTool : ITool { Photo photo; int initialRotation; public StraightenTool(Photo photo) { this.photo = photo; initialRotation = photo.RotationDegreeHundredths; } public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game, Photo photo, UiGeometry geometry) { if (input.IsKeyPressed(Keys.D0)) { photo.RotationDegreeHundredths = 0; } if (input.IsKeyPressed(Keys.Left)) { if (input.IsKeyDown(Keys.LeftControl)) { photo.RotationDegreeHundredths += 100; } else if (input.IsKeyDown(Keys.LeftShift)) { photo.RotationDegreeHundredths += 1; } else { photo.RotationDegreeHundredths += 10; } } if (input.IsKeyPressed(Keys.Right)) { if (input.IsKeyDown(Keys.LeftControl)) { photo.RotationDegreeHundredths -= 100; } else if (input.IsKeyDown(Keys.LeftShift)) { photo.RotationDegreeHundredths -= 1; } else { photo.RotationDegreeHundredths -= 10; } } if (input.IsKeyPressed(Keys.Enter)) { Matrix2 rotation = Matrix2.CreateRotation(MathHelper.DegreesToRadians(photo.RotationDegreeHundredths / 100f)); Vector2 center = new(photo.Size.X / 2f, photo.Size.Y / 2f); float[] xCoords = new float[4]; float[] yCoords = new float[4]; List corners = Util.RectangleCorners(new Rectangle(0, 0, photo.Size.X, photo.Size.Y)); for (int i = 0; i < 4; i++) { Vector2i corner = corners[i]; Vector2 rotated = Util.RotateAboutCenter(corner, center, rotation); xCoords[i] = rotated.X; yCoords[i] = rotated.Y; } Array.Sort(xCoords); Array.Sort(yCoords); for (int i = 3; i >= 0; i--) { xCoords[i] -= xCoords[0]; yCoords[i] -= yCoords[0]; } // FIXME: we can get a bigger crop using a better algorithm -- this is // too conservative. Also, preserve aspect ratio? Maybe we need to // commit this as a separate crop immediately so that further crops are // working on an image that isn't weirdly rotated. Or keep the // straighten-crop as a separate operation from an ordinary crop. int left = (int) Math.Ceiling(xCoords[1]); int right = (int) Math.Floor(xCoords[2]); int top = (int) Math.Ceiling(yCoords[1]); int bottom = (int) Math.Floor(yCoords[2]); photo.CropRectangle = Rectangle.FromLTRB(left, top, right, bottom); return ToolStatus.Done; } if (input.IsKeyPressed(Keys.Escape)) { photo.RotationDegreeHundredths = initialRotation; return ToolStatus.Canceled; } return ToolStatus.Active; } public string Status() { return String.Format("[straighten] {0:F2}", photo.RotationDegreeHundredths / 100f); } } public class UiGeometry { public static Vector2i MIN_WINDOW_SIZE = new(1024, 768); public readonly Vector2i WindowSize; public readonly Box2i ThumbnailBox; public readonly List ThumbnailBoxes = new(); public readonly List StarBoxes = new(); public readonly Box2i PhotoBox; public readonly Box2i StatusBox; public UiGeometry() : this(MIN_WINDOW_SIZE, 0) {} public UiGeometry(Vector2i windowSize, int starSize) { WindowSize = windowSize; int numThumbnailsPerColumn = Math.Max(WindowSize.Y / 100, 1); int thumbnailHeight = WindowSize.Y / numThumbnailsPerColumn; int thumbnailWidth = (int) (1.0 * thumbnailHeight * CameraInfo.AspectRatio); Console.WriteLine($"thumbnail size: {thumbnailWidth}x{thumbnailHeight}"); int thumbnailColumns = 3; for (int j = thumbnailColumns; j > 0; j--) { for (int i = 0; i < numThumbnailsPerColumn; i++) { Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth * j, i * thumbnailHeight, thumbnailWidth, thumbnailHeight); ThumbnailBoxes.Add(box); } } int statusBoxHeight = 40; PhotoBox = new Box2i( 0, 0, WindowSize.X - thumbnailWidth * thumbnailColumns, WindowSize.Y - statusBoxHeight); StatusBox = new Box2i( 0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth * thumbnailColumns, WindowSize.Y); ThumbnailBox = new Box2i( ThumbnailBoxes[0].Min.X, ThumbnailBoxes[0].Min.Y, WindowSize.X, WindowSize.Y); int starSpacing = 10; int starBoxLeft = (int) (PhotoBox.Center.X - 2.5 * starSize - starSpacing * 2); for (int i = 0; i < 5; i++) { Box2i box = Util.MakeBox( starBoxLeft + i * (starSize + starSpacing), PhotoBox.Max.Y - starSize - 10, starSize, starSize); StarBoxes.Add(box); } } } public static class Util { public const float PI = (float) Math.PI; public static int Lerp(int start, int end, double fraction) { return start + (int) ((end - start) * fraction); } public static Box2i MakeBox(int left, int top, int width, int height) { return new Box2i(left, top, left + width, top + height); } // resulting items are ordered such that a corner's opposite is 2 indexes away. public static List RectangleCorners(Rectangle r) { List result = new(4); result.Add(new(r.Left, r.Top)); result.Add(new(r.Right, r.Top)); result.Add(new(r.Right, r.Bottom)); result.Add(new(r.Left, r.Bottom)); return result; } public static Vector2 RotateAboutCenter(Vector2 vec, Vector2 center, Matrix2 transform) { Vector2 centerRelative = vec - center; centerRelative *= transform; return centerRelative + center; } public static Image MakeImage(float width, float height) { return new((int) Math.Ceiling(width), (int) Math.Ceiling(height)); } // https://sirv.com/help/articles/rotate-photos-to-be-upright/ // FIXME: could use AutoOrientProcessor instead? public static void RotateImageFromExif(Image image, ushort orientation) { if (orientation <= 1) { return; } var operations = new Dictionary { { 2, (RotateMode.None, FlipMode.Horizontal) }, { 3, (RotateMode.Rotate180, FlipMode.None) }, { 4, (RotateMode.None, FlipMode.Vertical) }, { 5, (RotateMode.Rotate90, FlipMode.Vertical) }, { 6, (RotateMode.Rotate90, FlipMode.None) }, { 7, (RotateMode.Rotate270, FlipMode.Vertical) }, { 8, (RotateMode.Rotate270, FlipMode.None) }, }; var (rotate, flip) = operations[orientation]; image.Mutate(x => x.RotateFlip(rotate, flip)); } public static Texture RenderText(string text) { return RenderText(text, 16); } public static Texture RenderText(string text, int size) { // Make sure that 0-length text doesn't end up as a 0-size texture, which // might cause problems. if (text.Length == 0) { text = " "; } Font font = SystemFonts.CreateFont("Consolas", size, FontStyle.Bold); TextOptions options = new(font); FontRectangle rect = TextMeasurer.Measure(text, new TextOptions(font)); Image image = MakeImage(rect.Width, rect.Height); IBrush brush = Brushes.Solid(Color.White); image.Mutate(x => x.DrawText(options, text, brush)); Texture texture = new Texture(image); image.Dispose(); return texture; } // FIXME: make a real icon stored as a PNG... public static OpenTK.Windowing.Common.Input.Image[] RenderAppIcon() { int size = 64; Font font = SystemFonts.CreateFont("MS Mincho", size, FontStyle.Bold); TextOptions options = new(font); Image image = MakeImage(size, size); IBrush brush = Brushes.Solid(Color.Black); image.Mutate(x => x.DrawText(options, "撮", brush)); byte[] pixelBytes = new byte[size * size * 4]; image.CopyPixelDataTo(pixelBytes); image.Dispose(); OpenTK.Windowing.Common.Input.Image opentkImage = new(size, size, pixelBytes); return new OpenTK.Windowing.Common.Input.Image[]{ opentkImage }; } public static Texture RenderStar(float radius, bool filled) { IPath path = new Star(x: radius, y: radius + 1, prongs: 5, innerRadii: radius * 0.4f, outerRadii: radius, angle: Util.PI); // We add a little bit to the width & height because the reported // path.Bounds are often a little tighter than they should be & a couple // pixels end up obviously missing... Image image = MakeImage(path.Bounds.Width + 2, path.Bounds.Height + 2); IBrush brush = Brushes.Solid(Color.White); IPen white = Pens.Solid(Color.White, 1.5f); IPen black = Pens.Solid(Color.Black, 3f); image.Mutate(x => x.Draw(black, path)); if (filled) { image.Mutate(x => x.Fill(brush, path)); } image.Mutate(x => x.Draw(white, path)); Texture texture = new Texture(image); image.Dispose(); return texture; } } public class Toast { private string message = ""; private double time; private double expiryTime; public void Set(string message) { this.message = message; this.expiryTime = time + 5.0; } public string Get() { return message; } public void Update(double elapsed) { time += elapsed; if (time > expiryTime) { message = ""; } } } public class Game : GameWindow { public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) : base(gwSettings, nwSettings) { activeTool = viewTool; geometry = new UiGeometry(nwSettings.Size, STAR_FILLED.Size.X); } private static string outputRoot = @"c:\users\colin\desktop\totte-output"; // private static string outputRoot = @"c:\users\colin\pictures\photos"; private static Texture TEXTURE_WHITE = new(new Image(1, 1, new Rgba32(255, 255, 255))); private static Texture TEXTURE_BLACK = new(new Image(1, 1, new Rgba32(0, 0, 0))); private static Texture STAR_FILLED = Util.RenderStar(20, true); private static Texture STAR_EMPTY = Util.RenderStar(20, false); private static Texture STAR_SMALL = Util.RenderStar(6, true); UiGeometry geometry; FpsCounter fpsCounter = new(); // Four points, each consisting of (x, y, z, tex_x, tex_y). float[] vertices = new float[20]; // Indices to draw a rectangle from two triangles. uint[] indices = { 0, 1, 3, // first triangle 1, 2, 3 // second triangle }; int VertexBufferObject; int ElementBufferObject; int VertexArrayObject; List allPhotos = new(); List photos = new(); HashSet loadedImages = new(); HashSet loadingImages = new(); readonly object loadedImagesLock = new(); readonly ViewTool viewTool = new ViewTool(); Toast toast = new(); ITool activeTool; int photoIndex = 0; int ribbonIndex = 0; Vector2i mousePosition; float activeScale = 1f; Vector2i activeOffset; Transform transform = new(1f, Vector2i.Zero, Vector2i.Zero); Shader shader = new(); Matrix4 projection; float zoomLevel = 0f; double timeSinceEvent = 0; // Variables that are protected by locks: readonly object numThumbnailsLoadedLock = new(); int numThumbnailsLoaded = 0; readonly object exportPhotosLock = new(); // locks the entire ExportPhotos() function. int numPhotosToExport = 0; readonly object numPhotosExportedLock = new(); int numPhotosExported = 0; protected override void OnUpdateFrame(FrameEventArgs e) { base.OnUpdateFrame(e); toast.Update(e.Time); KeyboardState input = KeyboardState; bool mouseInWindow = ClientRectangle.ContainsInclusive((Vector2i) MouseState.Position); if (input.IsAnyKeyDown || MouseState.IsAnyButtonDown || (mouseInWindow && MouseState.Delta != Vector2i.Zero)) { timeSinceEvent = 0; } else { timeSinceEvent += e.Time; } if (IsFocused && timeSinceEvent < 1) { RenderFrequency = 30; UpdateFrequency = 30; } else { RenderFrequency = 5; UpdateFrequency = 5; } Photo previousPhoto = photos[photoIndex]; bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift); bool altIsDown = input.IsKeyDown(Keys.LeftAlt) || input.IsKeyDown(Keys.RightAlt); bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl); // FIXME: add a confirm dialog before closing. (Also for the window-close button.) // FIXME: don't quit if there's pending file-write operations. // Close when Ctrl-Q is pressed. if (input.IsKeyPressed(Keys.Q) && ctrlIsDown) { Close(); } mousePosition = (Vector2i) MouseState.Position; // Look for mouse clicks on thumbnails or stars. // // Note that we don't bounds-check photoIndex until after all the possible // inputs that might affect it. That simplifies this logic significantly. if (MouseState.IsButtonPressed(MouseButton.Button1)) { for (int i = 0; i < geometry.StarBoxes.Count; i++) { if (geometry.StarBoxes[i].ContainsInclusive(mousePosition)) { photos[photoIndex].Rating = i + 1; } } for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) { if (geometry.ThumbnailBoxes[i].ContainsInclusive(mousePosition)) { photoIndex = ribbonIndex + i; } } } if (MouseState.IsButtonPressed(MouseButton.Button4)) { photoIndex--; } if (MouseState.IsButtonPressed(MouseButton.Button5)) { photoIndex++; } if (MouseState.ScrollDelta.Y < 0) { photoIndex++; } if (MouseState.ScrollDelta.Y > 0) { photoIndex--; } if (input.IsKeyPressed(Keys.Down)) { photoIndex++; } if (input.IsKeyPressed(Keys.Up)) { photoIndex--; } if (input.IsKeyPressed(Keys.Home)) { photoIndex = 0; } if (input.IsKeyPressed(Keys.End)) { photoIndex = photos.Count - 1; } if (input.IsKeyPressed(Keys.PageDown)) { photoIndex += 10; } if (input.IsKeyPressed(Keys.PageUp)) { photoIndex -= 10; } if (input.IsKeyPressed(Keys.P) && ctrlIsDown) { ExportPhotos(); } // Make sure the photoIndex is actually valid. if (photos.Count == 0) { photoIndex = 0; } else { photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1); } // Handle presses of the "rating" keys -- 0-5 and `. // A normal press just sets the rating of the current photo. // If the user is holding "shift", we instead filter to only show photos // of that rating or higher. int rating = -1; if (input.IsKeyPressed(Keys.D0) || input.IsKeyPressed(Keys.GraveAccent)) { rating = 0; } if (input.IsKeyPressed(Keys.D1)) { rating = 1; } if (input.IsKeyPressed(Keys.D2)) { rating = 2; } if (input.IsKeyPressed(Keys.D3)) { rating = 3; } if (input.IsKeyPressed(Keys.D4)) { rating = 4; } if (input.IsKeyPressed(Keys.D5)) { rating = 5; } if (rating >= 0) { if (shiftIsDown) { FilterByRating(rating); } else { if (photos.Count > 0) { photos[photoIndex].Rating = rating; } } } if (input.IsKeyPressed(Keys.Q)) { if (photos[photoIndex].CropRectangle != Rectangle.Empty) { Photo photo = photos[photoIndex]; Rectangle r = photos[photoIndex].CropRectangle; photo.ViewOffset = new(photo.Size.X / 2 - Rectangle.Center(r).X, photo.Size.Y / 2 - Rectangle.Center(r).Y); } else { photos[photoIndex].ViewOffset = Vector2i.Zero; } zoomLevel = 0f; } if (input.IsKeyPressed(Keys.W)) { zoomLevel = 1f; } if (input.IsKeyPressed(Keys.E)) { zoomLevel = 2f; } if (input.IsKeyPressed(Keys.R)) { zoomLevel = 4f; } if (input.IsKeyPressed(Keys.T)) { zoomLevel = 8f; } if (input.IsKeyPressed(Keys.Y)) { zoomLevel = 16f; } // Handle tool switching. // FIXME: prevent photo switching when tools other than ViewTool are active? if (photos[photoIndex] != previousPhoto) { activeTool = viewTool; } if (activeTool == viewTool) { if (input.IsKeyPressed(Keys.C)) { activeTool = new CropTool(photos[photoIndex]); } if (input.IsKeyPressed(Keys.X)) { activeTool = new StraightenTool(photos[photoIndex]); } } // Delegate input to the active tool. ToolStatus status = activeTool.HandleInput( KeyboardState, MouseState, transform, this, photos[photoIndex], geometry); // Change back to the default tool if the active tool is done. if (status != ToolStatus.Active) { activeTool = viewTool; } } void FilterByRating(int rating) { Console.WriteLine("filter to " + rating); Photo previouslyActive = photos.Count > 0 ? photos[photoIndex] : allPhotos[0]; photos = allPhotos.Where(p => p.Rating >= rating).ToList(); // Move photoIndex to wherever the previously active photo was, or if it // was filtered out, to whichever unfiltered photo comes before it. This // is O(n) in the length of allPhotos, but how bad can it be? :) photoIndex = -1; for (int i = 0; i < allPhotos.Count; i++) { Photo candidate = allPhotos[i]; if (candidate.Rating >= rating) { photoIndex++; } if (candidate == previouslyActive) { break; } } photoIndex = Math.Max(0, photoIndex); } // FIXME: switch to immediate mode?? // https://gamedev.stackexchange.com/questions/198805/opentk-immediate-mode-on-net-core-doesnt-work // https://www.youtube.com/watch?v=Q23Kf9QEaO4 protected override void OnLoad() { base.OnLoad(); GL.ClearColor(0f, 0f, 0f, 1f); GL.Enable(EnableCap.Blend); GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); VertexArrayObject = GL.GenVertexArray(); GL.BindVertexArray(VertexArrayObject); VertexBufferObject = GL.GenBuffer(); ElementBufferObject = GL.GenBuffer(); GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject); GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.DynamicDraw); GL.BindBuffer(BufferTarget.ElementArrayBuffer, ElementBufferObject); GL.BufferData(BufferTarget.ElementArrayBuffer, indices.Length * sizeof(uint), indices, BufferUsageHint.DynamicDraw); shader.Init(); shader.Use(); // Because there's 5 floats between the start of the first vertex and the start of the second, // the stride is 5 * sizeof(float). // This will now pass the new vertex array to the buffer. var vertexLocation = shader.GetAttribLocation("aPosition"); GL.EnableVertexAttribArray(vertexLocation); GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), 0); // Next, we also setup texture coordinates. It works in much the same way. // We add an offset of 3, since the texture coordinates comes after the position data. // We also change the amount of data to 2 because there's only 2 floats for texture coordinates. var texCoordLocation = shader.GetAttribLocation("aTexCoord"); GL.EnableVertexAttribArray(texCoordLocation); GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), 3 * sizeof(float)); // Load photos from a directory. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\photos-test\"); // string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\14\"); string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\09\06\jpg"); // string[] files = Directory.GetFiles(@"G:\DCIM\100EOSR6\"); // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\08\03"); // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\export"); // string[] files = Directory.GetFiles(@"C:\Users\colin\Pictures\photos\2018\06\23"); // string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\Germany all\104D7000"); // string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\many-birds\"); for (int i = 0; i < files.Count(); i++) { string file = files[i]; if (file.ToLower().EndsWith(".jpg")) { Photo photo = new Photo(file, TEXTURE_BLACK); allPhotos.Add(photo); } } allPhotos.Sort(ComparePhotosByDate); // Fix up photos with missing GPS. We start at the end and work our way // backwards, because if one photo is missing GPS, it's probably because // the camera was turned off for a while, and whichever photo *after* it // has GPS data is probably more accurate. GpsInfo? lastGps = null; for (int i = allPhotos.Count - 1; i >= 0; i--) { Photo p = allPhotos[i]; if (p.Gps == null) { Console.WriteLine("fixing GPS for " + p.Filename); p.Gps = lastGps; } else { lastGps = p.Gps; } } photos = allPhotos; LoadThumbnailsAsync(); } private static int ComparePhotosByDate(Photo x, Photo y) { int compare = x.DateTimeOriginal.CompareTo(y.DateTimeOriginal); if (compare != 0) { return compare; } // If the photos have the same seconds value, sort by filename // (since cameras usually increment the filename for successive shots.) return x.Filename.CompareTo(y.Filename); } protected override void OnUnload() { base.OnUnload(); } private void UnloadImages() { // Unload images that haven't been touched in a while. lock (loadedImagesLock) { while (loadedImages.Count > 30) { long earliestTime = long.MaxValue; Photo? earliest = null; foreach (Photo photo in loadedImages) { if (photo.LastTouch < earliestTime) { earliest = photo; earliestTime = photo.LastTouch; } } if (earliest != null) { Console.WriteLine($"loadedImages.Count: {loadedImages.Count}, " + $"evicting {earliest.Filename} @ {earliestTime}"); earliest.Unload(); loadedImages.Remove(earliest); } } } } private async void LoadImagesAsync() { foreach (Photo p in loadingImages) { if (p.Loaded) { lock (loadedImagesLock) { loadedImages.Add(p); loadingImages.Remove(p); } } } // Start loading any images that are in our window but not yet loaded. int minLoadedImage = Math.Max(0, photoIndex - 10); int maxLoadedImage = Math.Min(photoIndex + 10, photos.Count - 1); List toLoad = new(); for (int i = minLoadedImage; i <= maxLoadedImage; i++) { lock (loadedImagesLock) { if (!loadedImages.Contains(photos[i]) && !loadingImages.Contains(photos[i])) { Console.WriteLine("loading " + i); loadingImages.Add(photos[i]); toLoad.Add(photos[i]); } } } foreach (Photo p in toLoad) { await Task.Run( () => { p.LoadAsync(p.Size); }); } } private async void LoadThumbnailsAsync() { List tasks = new(); foreach (Photo p in allPhotos) { tasks.Add(Task.Run( () => { p.LoadThumbnailAsync(new Vector2i(256, 256)); lock (numThumbnailsLoadedLock) { numThumbnailsLoaded++; toast.Set($"[{numThumbnailsLoaded}/{allPhotos.Count}] Loading thumbnails"); } })); } await Task.WhenAll(tasks).ContinueWith(t => { toast.Set("Loading thumbnails: done!"); }); } // To find the JPEG compression level of a file from the command line: // $ identify -verbose image.jpg | grep Quality: private async void ExportPhotos() { List tasks = new(); lock (exportPhotosLock) { // Don't ExportPhotos() if one is already active. lock (numPhotosExportedLock) { if (numPhotosToExport > 0 && numPhotosExported != numPhotosToExport) { Console.WriteLine("ExportPhotos: skipping because another export is already in progress."); return; } } numPhotosToExport = photos.Count; numPhotosExported = 0; foreach (Photo p in photos) { tasks.Add(Task.Run( () => { p.SaveAsJpegAsync(outputRoot); lock (numPhotosExportedLock) { numPhotosExported++; toast.Set($"[{numPhotosExported}/{numPhotosToExport}] Exported {outputRoot}/{p.Filename}"); } })); } } await Task.WhenAll(tasks).ContinueWith(t => { toast.Set("Exporting photos: done!"); }); } protected override void OnRenderFrame(FrameEventArgs e) { base.OnRenderFrame(e); fpsCounter.Update(); UnloadImages(); LoadImagesAsync(); GL.Clear(ClearBufferMask.ColorBufferBit); GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject); GL.ActiveTexture(TextureUnit.Texture0); if (photos.Count > 0) { DrawPhotos(); } else { DrawText("No photos found.", 10, 10); } SwapBuffers(); } void DrawPhotos() { Photo activePhoto = photos[photoIndex]; Texture active = activePhoto.Texture(); bool cropActive = activeTool is CropTool; bool straightenActive = activeTool is StraightenTool; // FIXME: make a function for scaling & centering one box on another. // FIXME: cropping is fucky because activeScale is using the texture size, not the photo size. float scaleX = 1f * geometry.PhotoBox.Size.X / active.Size.X; float scaleY = 1f * geometry.PhotoBox.Size.Y / active.Size.Y; float scale = Math.Min(scaleX, scaleY); if (zoomLevel > 0f) { scale = zoomLevel; } else if (!cropActive && activePhoto.CropRectangle != Rectangle.Empty) { scale *= 0.95f * active.Size.X / activePhoto.CropRectangle.Width; } activeScale = scale; Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale); Vector2i center = (Vector2i) geometry.PhotoBox.Center; Vector2i offset = new((int) (activePhoto.ViewOffset.X * scale), (int) (activePhoto.ViewOffset.Y * scale)); Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2 + offset.X, center.Y - renderSize.Y / 2 + offset.Y, renderSize.X, renderSize.Y); activeOffset = new(photoBox.Min.X, photoBox.Min.Y); transform = new Transform(activeScale, activeOffset, activePhoto.Size); DrawTexture(active, photoBox, Color4.White, activePhoto.RotationDegreeHundredths / 100f); for (int i = 0; i < 5; i++) { Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY; DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y); } if (straightenActive) { DrawStraightenGuides(); } else { DrawCropRectangle(cropActive); } // Draw thumbnail boxes. ribbonIndex = Math.Clamp(photoIndex - (geometry.ThumbnailBoxes.Count - 1) / 2, 0, Math.Max(0, photos.Count - geometry.ThumbnailBoxes.Count)); DrawFilledBox(geometry.ThumbnailBox, Color4.Black); for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) { if (ribbonIndex + i >= photos.Count) { break; } Photo photo = photos[ribbonIndex + i]; Box2i box = geometry.ThumbnailBoxes[i]; DrawTexture(photo.ThumbnailTexture(), box); for (int j = 0; j < photo.Rating; j++) { DrawTexture(STAR_SMALL, box.Min.X + 8 + ((STAR_SMALL.Size.X + 2) * j), box.Min.Y + 8); } if (ribbonIndex + i == photoIndex) { DrawBox(box, 5, Color4.Black); DrawBox(box, 3, Color4.White); } } // Draw status box. int statusPadding = 4; DrawFilledBox(geometry.StatusBox, Color4.Black); // First line. int y = geometry.StatusBox.Min.Y + statusPadding; DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count), geometry.StatusBox.Min.X, y); DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 88, y); // Second line. y += 20; string status = activeTool.Status(); if (status.Length != 0) { status += " "; } DrawText(status + toast.Get(), geometry.StatusBox.Min.X, y); DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, y); if (activePhoto.Loaded) { DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Max.X - 136, y); } } void DrawCropRectangle(bool active) { Photo activePhoto = photos[photoIndex]; if (activePhoto.CropRectangle == Rectangle.Empty) { return; } Vector2i leftTop = transform.ImageToScreen(activePhoto.CropRectangle.Left, activePhoto.CropRectangle.Top); Vector2i rightBottom = transform.ImageToScreen(activePhoto.CropRectangle.Right, activePhoto.CropRectangle.Bottom); var (left, top) = leftTop; var (right, bottom) = rightBottom; Color4 shadeColor = new Color4(0, 0, 0, 0.75f); DrawFilledBox(new Box2i(0, 0, left, geometry.PhotoBox.Max.Y), shadeColor); DrawFilledBox(new Box2i(left, 0, geometry.PhotoBox.Max.X, top), shadeColor); DrawFilledBox(new Box2i(left, bottom, geometry.PhotoBox.Max.X, geometry.PhotoBox.Max.Y), shadeColor); DrawFilledBox(new Box2i(right, top, geometry.PhotoBox.Max.X, bottom), shadeColor); DrawBox(new Box2i(left - 1, top - 1, right + 1, bottom + 1), 1, Color4.White); if (active) { // handles int handleThickness = 3; int handleLength = 16; // top-left DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness, left + handleLength, top), Color4.White); DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness, left, top + handleLength), Color4.White); // top-right DrawFilledBox(new Box2i(right - handleLength, top - handleThickness, right + handleThickness, top), Color4.White); DrawFilledBox(new Box2i(right, top - handleThickness, right + handleThickness, top + handleLength), Color4.White); // bottom-left DrawFilledBox(new Box2i(left - handleThickness, bottom, left + handleLength, bottom + handleThickness), Color4.White); DrawFilledBox(new Box2i(left - handleThickness, bottom - handleLength, left, bottom + handleThickness), Color4.White); // bottom-right DrawFilledBox(new Box2i(right - handleLength, bottom, right + handleThickness, bottom + handleThickness), Color4.White); DrawFilledBox(new Box2i(right + handleThickness, bottom - handleLength, right, bottom + handleThickness), Color4.White); // thirds DrawHorizontalLine(left, Util.Lerp(top, bottom, 1.0 / 3), right, Color4.White); DrawHorizontalLine(left, Util.Lerp(top, bottom, 2.0 / 3), right, Color4.White); DrawVerticalLine(Util.Lerp(left, right, 1.0 / 3), top, bottom, Color4.White); DrawVerticalLine(Util.Lerp(left, right, 2.0 / 3), top, bottom, Color4.White); } } void DrawStraightenGuides() { Box2i box = geometry.PhotoBox; int lineSpacing = 48; Color4 color = new(1f, 1f, 1f, 0.5f); for (int y = box.Min.Y + lineSpacing / 2; y < box.Max.Y; y += lineSpacing) { DrawHorizontalLine(box.Min.X, y, box.Max.X, color); // DrawHorizontalLine(box.Min.X, y + 1, box.Max.X, color); } for (int x = box.Min.X + lineSpacing / 2; x < box.Max.X; x += lineSpacing) { DrawVerticalLine(x, box.Min.Y, box.Max.Y, color); // DrawVerticalLine(x + 1, box.Min.Y, box.Max.Y, color); } } public void DrawTexture(Texture texture, int x, int y) { DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y)); } public void DrawTexture(Texture texture, Box2i box) { DrawTexture(texture, box, Color4.White); } public void DrawTexture(Texture texture, Box2i box, Color4 color) { DrawTexture(texture, box, color, 0); } public void DrawTexture(Texture texture, Box2i box, Color4 color, float rotationDegrees) { GL.Uniform4(shader.GetUniformLocation("color"), color); SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y, rotationDegrees); GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.DynamicDraw); GL.BindTexture(TextureTarget.Texture2D, texture.Handle); GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0); } public void DrawHorizontalLine(int left, int top, int right, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color); } public void DrawVerticalLine(int left, int top, int bottom, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color); } public void DrawBox(Box2i box, int thickness, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, thickness), color); DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, thickness, box.Size.Y), color); DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Max.Y - thickness, box.Size.X, thickness), color); DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Max.X - thickness, box.Min.Y, thickness, box.Size.Y), color); } public void DrawFilledBox(Box2i box, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color); } public void DrawText(string text, int x, int y) { Texture label = Util.RenderText(text); DrawTexture(label, x, y); label.Dispose(); } protected override void OnResize(ResizeEventArgs e) { base.OnResize(e); Console.WriteLine($"OnResize: {e.Width}x{e.Height}"); geometry = new UiGeometry(e.Size, STAR_FILLED.Size.X); projection = Matrix4.CreateOrthographicOffCenter(0f, e.Width, e.Height, 0f, -1f, 1f); GL.UniformMatrix4(shader.GetUniformLocation("projection"), true, ref projection); GL.Viewport(0, 0, e.Width, e.Height); } private void SetVertices(float left, float top, float width, float height, float rotationDegrees) { Matrix2 transform = Matrix2.CreateRotation(MathHelper.DegreesToRadians(rotationDegrees)); Vector2 center = new(left + width / 2, top + height / 2); Vector2 topLeft = Util.RotateAboutCenter(new Vector2(left, top), center, transform); Vector2 topRight = Util.RotateAboutCenter(new Vector2(left + width, top), center, transform); Vector2 bottomRight = Util.RotateAboutCenter(new Vector2(left + width, top + height), center, transform); Vector2 bottomLeft = Util.RotateAboutCenter(new Vector2(left, top + height), center, transform); // top left vertices[0] = topLeft.X; vertices[1] = topLeft.Y; vertices[2] = 0f; vertices[3] = 0f; vertices[4] = 0f; // top right vertices[5] = topRight.X; vertices[6] = topRight.Y; vertices[7] = 0f; vertices[8] = 1f; vertices[9] = 0f; // bottom right vertices[10] = bottomRight.X; vertices[11] = bottomRight.Y; vertices[12] = 0f; vertices[13] = 1f; vertices[14] = 1f; // bottom left vertices[15] = bottomLeft.X; vertices[16] = bottomLeft.Y; vertices[17] = 0f; vertices[18] = 0f; vertices[19] = 1f; } } static class Program { static void Main(string[] args) { List monitors = Monitors.GetMonitors(); MonitorInfo bestMonitor = monitors[0]; int bestResolution = bestMonitor.HorizontalResolution * bestMonitor.VerticalResolution; for (int i = 1; i < monitors.Count; i++) { MonitorInfo monitor = monitors[i]; int resolution = monitor.HorizontalResolution * monitor.VerticalResolution; if (resolution > bestResolution) { bestResolution = resolution; bestMonitor = monitor; } } Console.WriteLine( $"best monitor: {bestMonitor.HorizontalResolution}x{bestMonitor.VerticalResolution}"); GameWindowSettings gwSettings = new(); gwSettings.UpdateFrequency = 30.0; gwSettings.RenderFrequency = 30.0; NativeWindowSettings nwSettings = new(); nwSettings.WindowState = WindowState.Normal; nwSettings.CurrentMonitor = bestMonitor.Handle; nwSettings.Location = new Vector2i(bestMonitor.WorkArea.Min.X + 1, bestMonitor.WorkArea.Min.Y + 31); nwSettings.Size = new Vector2i(bestMonitor.WorkArea.Size.X - 2, bestMonitor.WorkArea.Size.Y - 32); // nwSettings.Size = new Vector2i(1600, 900); nwSettings.MinimumSize = UiGeometry.MIN_WINDOW_SIZE; nwSettings.Title = "Totte"; nwSettings.IsEventDriven = false; nwSettings.Icon = new WindowIcon(Util.RenderAppIcon()); using (Game game = new(gwSettings, nwSettings)) { game.Run(); } } }