using OpenTK.Graphics.OpenGL4; using OpenTK.Mathematics; using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; using OpenTK.Windowing.GraphicsLibraryFramework; // https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Image.html using Image = SixLabors.ImageSharp.Image; using SixLabors.Fonts; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing; using System.Runtime.CompilerServices; namespace SemiColinGames; public class CameraInfo { public readonly Vector2i Resolution; private CameraInfo(Vector2i resolution) { Resolution = resolution; } public static readonly CameraInfo NIKON_D7000 = new(new Vector2i(4928, 3264)); public static readonly CameraInfo CANON_EOS_R6M2 = new(new Vector2i(6000, 4000)); public static readonly CameraInfo IPHONE_12_MINI = new(new Vector2i(4032, 3024)); } public class Shader : IDisposable { public int Handle; private bool init = false; public Shader() {} public void Init() { init = true; int VertexShader; int FragmentShader; string VertexShaderSource = @" #version 330 layout(location = 0) in vec3 aPosition; layout(location = 1) in vec2 aTexCoord; out vec2 texCoord; uniform mat4 projection; void main(void) { texCoord = aTexCoord; gl_Position = vec4(aPosition, 1.0) * projection; }"; string FragmentShaderSource = @" #version 330 out vec4 outputColor; in vec2 texCoord; uniform sampler2D texture0; uniform vec4 color; void main() { outputColor = texture(texture0, texCoord) * color; }"; VertexShader = GL.CreateShader(ShaderType.VertexShader); GL.ShaderSource(VertexShader, VertexShaderSource); FragmentShader = GL.CreateShader(ShaderType.FragmentShader); GL.ShaderSource(FragmentShader, FragmentShaderSource); GL.CompileShader(VertexShader); int success; GL.GetShader(VertexShader, ShaderParameter.CompileStatus, out success); if (success == 0) { string infoLog = GL.GetShaderInfoLog(VertexShader); Console.WriteLine(infoLog); } GL.CompileShader(FragmentShader); GL.GetShader(FragmentShader, ShaderParameter.CompileStatus, out success); if (success == 0) { string infoLog = GL.GetShaderInfoLog(FragmentShader); Console.WriteLine(infoLog); } Handle = GL.CreateProgram(); GL.AttachShader(Handle, VertexShader); GL.AttachShader(Handle, FragmentShader); GL.LinkProgram(Handle); GL.GetProgram(Handle, GetProgramParameterName.LinkStatus, out success); if (success == 0) { string infoLog = GL.GetProgramInfoLog(Handle); Console.WriteLine(infoLog); } GL.DetachShader(Handle, VertexShader); GL.DetachShader(Handle, FragmentShader); GL.DeleteShader(FragmentShader); GL.DeleteShader(VertexShader); } public void Use() { if (!init) { Console.WriteLine("Shader.Use(): must call Init() first"); } GL.UseProgram(Handle); } private bool disposedValue = false; protected virtual void Dispose(bool disposing) { if (!disposedValue) { GL.DeleteProgram(Handle); disposedValue = true; } } ~Shader() { if (disposedValue == false) { Console.WriteLine("~Shader(): resource leak? Dispose() should be called manually."); } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } public int GetAttribLocation(string name) { return GL.GetAttribLocation(Handle, name); } public int GetUniformLocation(string name) { return GL.GetUniformLocation(Handle, name); } } // FIXME: this should probably be IDisposable? public class Photo { public string File; public bool Loaded = false; public string? CameraModel; public string? LensModel; public string FocalLength = ""; public string FNumber = ""; public string ExposureTime = ""; public string IsoSpeed = ""; private Texture texture; private Texture placeholder; private Image? image = null; public Photo(string file, Texture placeholder) { File = file; this.placeholder = placeholder; texture = placeholder; } public async void Load() { image = await Image.LoadAsync(File); ExifProfile? exifs = image.Metadata.ExifProfile; if (exifs != null) { // FIXME: handle Orientation IExifValue? model; if (exifs.TryGetValue(ExifTag.Model, out model)) { CameraModel = model.Value; } IExifValue? lensModel; if (exifs.TryGetValue(ExifTag.LensModel, out lensModel)) { LensModel = lensModel.Value; } IExifValue? focalLength; if (exifs.TryGetValue(ExifTag.FocalLength, out focalLength)) { Rational r = focalLength.Value; FocalLength = $"{r.Numerator / r.Denominator}mm"; } IExifValue? fNumber; if (exifs.TryGetValue(ExifTag.FNumber, out fNumber)) { Rational r = fNumber.Value; if (r.Denominator == 1) { FNumber = $"f/{r.Numerator}"; } else { if (r.Denominator != 10) { Console.WriteLine($"*** WARNING: unexpected FNumber denominator: {r.Denominator}"); } if (r.Numerator % 10 == 0) { FNumber = $"f/{r.Numerator / 10}"; } else { FNumber= $"f/{r.Numerator / 10}.{r.Numerator % 10}"; } } } IExifValue? exposureTime; if (exifs.TryGetValue(ExifTag.ExposureTime, out exposureTime)) { Rational r = exposureTime.Value; if (r.Numerator == 1) { ExposureTime = $"1/{r.Denominator}"; } else if (r.Numerator == 10) { ExposureTime = $"1/{r.Denominator / 10}"; } else if (r.Denominator == 1) { ExposureTime = $"{r.Numerator }\""; } else if (r.Denominator == 10) { ExposureTime = $"{r.Numerator / 10}.{r.Numerator % 10}\""; } else { Console.WriteLine($"*** WARNING: unexpected ExposureTime: {r.Numerator}/{r.Denominator}"); ExposureTime = r.ToString(); } } IExifValue? isoSpeed; if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out isoSpeed)) { ushort[]? iso = isoSpeed.Value; if (iso != null) { if (iso.Length != 1) { Console.WriteLine($"*** WARNING: unexpected ISOSpeedRatings array length: {iso.Length}"); } if (iso.Length >= 1) { IsoSpeed = $"ISO {iso[0]}"; } } } // foreach (IExifValue exif in exifs.Values) { // Console.WriteLine(exif.Tag.ToString() + " " + exif.GetValue().ToString()); // } } } public Texture Texture() { if (texture == placeholder && image != null) { texture = new Texture(image); image.Dispose(); image = null; Loaded = true; } return texture; } public string Description() { if (Loaded) { string shootingInfo = $"{FocalLength}, {FNumber} at {ExposureTime}, {IsoSpeed}"; return String.Format("{0,-40} {1,-50} {2}", shootingInfo, $"{CameraModel} {LensModel}", File); } else { return String.Format("{0,-90} {1}", "", File); } } } public class Texture : IDisposable { public int Handle; public Vector2i Size; public Texture(Image image) { Size = new Vector2i(image.Width, image.Height); byte[] pixelBytes = new byte[Size.X * Size.Y * Unsafe.SizeOf()]; image.CopyPixelDataTo(pixelBytes); Handle = GL.GenTexture(); if (Handle > maxHandle) { Console.WriteLine("GL.GenTexture #" + Handle); maxHandle = Handle; } GL.ActiveTexture(TextureUnit.Texture0); GL.BindTexture(TextureTarget.Texture2D, Handle); GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, Size.X, Size.Y, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixelBytes); //GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.LinearMipmapLinear); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.Linear); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int) TextureMagFilter.Nearest); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int) TextureWrapMode.ClampToBorder); GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int) TextureWrapMode.ClampToBorder); float[] borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }; GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureBorderColor, borderColor); // FIXME: should we use mipmaps? //GL.GenerateMipmap(GenerateMipmapTarget.Texture2D); } private static int maxHandle = -1; private bool disposedValue = false; protected virtual void Dispose(bool disposing) { if (!disposedValue) { GL.DeleteTexture(Handle); disposedValue = true; } } ~Texture() { if (!disposedValue) { Console.WriteLine("~Texture(): resource leak? Dispose() should be called manually."); } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } } public class UiGeometry { public static Vector2i MIN_WINDOW_SIZE = new(640, 480); private static CameraInfo activeCamera = CameraInfo.CANON_EOS_R6M2; public readonly Vector2i WindowSize; public readonly List ThumbnailBoxes = new(); public readonly Box2i PhotoBox; public readonly Box2i StatusBox; public UiGeometry() : this(MIN_WINDOW_SIZE) {} public UiGeometry(Vector2i windowSize) { WindowSize = windowSize; int numThumbnails = 20; int thumbnailHeight = WindowSize.Y / numThumbnails; int thumbnailWidth = (int) 1.0 * thumbnailHeight * activeCamera.Resolution.X / activeCamera.Resolution.Y; for (int i = 0; i < numThumbnails; i++) { Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth, i * thumbnailHeight, thumbnailWidth, thumbnailHeight); ThumbnailBoxes.Add(box); } int statusBoxHeight = 20; int statusBoxPadding = 4; PhotoBox = new Box2i(0, 0, WindowSize.X - thumbnailWidth, WindowSize.Y - statusBoxHeight - statusBoxPadding); StatusBox = new Box2i(0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth, WindowSize.Y); } } public static class Util { public const float PI = (float) Math.PI; public static Box2i MakeBox(int left, int top, int width, int height) { return new Box2i(left, top, left + width, top + height); } public static Image MakeImage(float width, float height) { return new((int) Math.Ceiling(width), (int) Math.Ceiling(height)); } public static Texture RenderText(string text) { return RenderText(text, 16); } public static Texture RenderText(string text, int size) { 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); // IPen pen = Pens.Solid(Color.Black, 1f); // image.Mutate(x => x.DrawText(options, text, brush, pen)); image.Mutate(x => x.DrawText(options, text, brush)); Texture texture = new Texture(image); image.Dispose(); return texture; } public static Texture RenderStar(float radius) { IPath path = new Star(x: radius, y: radius, prongs: 5, innerRadii: radius * 0.4f, outerRadii: radius, angle: Util.PI); Image image = MakeImage(path.Bounds.Width, path.Bounds.Height); image.Mutate(x => x.Fill(Color.White, path)); Texture texture = new Texture(image); image.Dispose(); return texture; } } public class Game : GameWindow { public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) : base(gwSettings, nwSettings) {} 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))); UiGeometry geometry = new(); // Input handling. long downTimer = Int64.MaxValue; long upTimer = Int64.MaxValue; // 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 photos = new(); int photoIndex = 0; Shader shader = new(); Matrix4 projection; float zoomLevel = 0f; protected override void OnUpdateFrame(FrameEventArgs e) { base.OnUpdateFrame(e); long now = DateTime.Now.Ticks; KeyboardState input = KeyboardState; // Close when Escape is pressed. if (input.IsKeyDown(Keys.Escape)) { Close(); } // Look for mouse clicks on thumbnails. if (MouseState.IsButtonPressed(0)) { for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) { Box2i box = geometry.ThumbnailBoxes[i]; if (box.ContainsInclusive((Vector2i) MouseState.Position)) { if (0 <= i && i < photos.Count) { photoIndex = i; } } } } // Track keyboard repeat times for advancing up/down. if (!input.IsKeyDown(Keys.Down)) { downTimer = Int64.MaxValue; } if (!input.IsKeyDown(Keys.Up)) { upTimer = Int64.MaxValue; } if (input.IsKeyDown(Keys.D0) || input.IsKeyDown(Keys.GraveAccent)) { zoomLevel = 0f; } if (input.IsKeyDown(Keys.D1)) { zoomLevel = 1f; } if (input.IsKeyDown(Keys.D2)) { zoomLevel = 2f; } if (input.IsKeyDown(Keys.D3)) { zoomLevel = 4f; } if (input.IsKeyDown(Keys.D4)) { zoomLevel = 8f; } if (input.IsKeyDown(Keys.D5)) { zoomLevel = 16f; } // FIXME: make a proper Model class for tracking the state of the controls? if (input.IsKeyPressed(Keys.Down) || now > downTimer) { if (photoIndex < photos.Count - 1) { downTimer = now + 10000 * 200; photoIndex++; } } if (input.IsKeyPressed(Keys.Up) || now > upTimer) { if (photoIndex > 0) { upTimer = now + 10000 * 200; photoIndex--; } } } protected override async 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 textures from JPEGs. 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(@"G:\DCIM\100EOSR6\"); // 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); photos.Add(photo); await Task.Run( () => { photo.Load(); }); if (photos.Count == 100) { break; } } } } protected override void OnUnload() { base.OnUnload(); } protected override void OnRenderFrame(FrameEventArgs e) { base.OnRenderFrame(e); GL.Clear(ClearBufferMask.ColorBufferBit); GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject); GL.ActiveTexture(TextureUnit.Texture0); Photo activePhoto = photos[photoIndex]; Texture active = activePhoto.Texture(); // FIXME: make a function for scaling & centering one box on another. 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; } Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale); // FIXME: clip by the actual PhotoBox size -- this bleeds over UI elements when zoomed in. // This doesn't work -- boo. // if (renderSize.X > geometry.PhotoBox.Size.X) { // renderSize.X = geometry.PhotoBox.Size.X; // } // if (renderSize.Y > geometry.PhotoBox.Size.Y) { // renderSize.Y = geometry.PhotoBox.Size.Y; // } Vector2i center = (Vector2i) geometry.PhotoBox.Center; Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2, center.Y - renderSize.Y / 2, renderSize.X, renderSize.Y); DrawTexture(active, photoBox); for (int i = 0; i < photos.Count && i < geometry.ThumbnailBoxes.Count(); i++) { Box2i box = geometry.ThumbnailBoxes[i]; DrawTexture(photos[i].Texture(), box); if (i == photoIndex) { DrawBox(box, 5, Color4.Black); DrawBox(box, 3, Color4.White); } } // Draw status box. DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 80, geometry.StatusBox.Min.Y); if (activePhoto.Loaded) { DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Min.X, geometry.StatusBox.Min.Y); } SwapBuffers(); } void DrawTexture(Texture texture, Box2i box) { DrawTexture(texture, box, Color4.White); } void DrawTexture(Texture texture, Box2i box, Color4 color) { GL.Uniform4(shader.GetUniformLocation("color"), color); SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y); 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); } 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); } void DrawText(string text, int x, int y) { Texture label = Util.RenderText(text); DrawTexture(label, Util.MakeBox(x, y, label.Size.X, label.Size.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); 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) { // top left vertices[0] = left; vertices[1] = top; vertices[2] = 0f; vertices[3] = 0f; vertices[4] = 0f; // top right vertices[5] = left + width; vertices[6] = top; vertices[7] = 0f; vertices[8] = 1f; vertices[9] = 0f; // bottom right vertices[10] = left + width; vertices[11] = top + height; vertices[12] = 0f; vertices[13] = 1f; vertices[14] = 1f; // bottom left vertices[15] = left; vertices[16] = top + height; 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.RenderFrequency = 60.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.MinimumSize = UiGeometry.MIN_WINDOW_SIZE; nwSettings.Title = "Totte"; // FIXME: nwSettings.Icon = ... using (Game game = new(gwSettings, nwSettings)) { game.Run(); } } }