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 Image = SixLabors.ImageSharp.Image; using SixLabors.Fonts; using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Metadata.Profiles.Xmp; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing; using SixLabors.ImageSharp.Formats.Jpeg; using System; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using System.Xml.Linq; 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 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)); } // 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 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 Filename; public bool Loaded = false; public long LastTouch = 0; public Vector2i Size; public DateTime DateTimeOriginal; public string CameraModel = ""; public string LensModel = ""; public string FocalLength = ""; public string FNumber = ""; public string ExposureTime = ""; public string IsoSpeed = ""; public int Rating = 0; public ushort Orientation = 1; private static long touchCounter = 0; private Texture texture; private Texture placeholder; private Image? image = null; public Photo(string filename, Texture placeholder) { Filename = filename; this.placeholder = placeholder; texture = placeholder; DateTime creationTime = File.GetCreationTime(filename); // Local time. DateTimeOriginal = creationTime; ImageInfo info = Image.Identify(filename); Size = new(info.Size.Width, info.Size.Height); Rating = ParseRating(info.Metadata.XmpProfile); ParseExif(info.Metadata.ExifProfile); } public async void LoadAsync() { // We don't assign to this.image until Load() is done, because we might // edit the image due to rotation (etc) and don't want to try generating // a texture for it until that's already happened. LastTouch = touchCounter++; Image tmp = await Image.LoadAsync(Filename); Util.RotateImageFromExif(tmp, Orientation); image = tmp; } public void Unload() { Loaded = false; if (texture != placeholder) { texture.Dispose(); texture = placeholder; } } public async void SaveAsJpegAsync(string outputRoot, JpegEncoder encoder) { // FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed? string directory = System.IO.Path.Combine( outputRoot, String.Format("{0:D4}", DateTimeOriginal.Year), String.Format("{0:D2}", DateTimeOriginal.Month), String.Format("{0:D2}", DateTimeOriginal.Day)); Directory.CreateDirectory(directory); string filename = System.IO.Path.Combine(directory, System.IO.Path.GetFileName(Filename)); Console.WriteLine("saving " + filename); // FIXME: add comments / captions as ImageDescription? // FIXME: strip some Exif tags for privacy reasons? // FIXME: warn if the file already exists? using (Image image = await Image.LoadAsync(Filename)) { Util.RotateImageFromExif(image, Orientation); ExifProfile exif = image.Metadata.ExifProfile ?? new(); exif.SetValue(ExifTag.Orientation, 1); exif.SetValue(ExifTag.Artist, "Colin McMillen"); exif.SetValue(ExifTag.Copyright, "Colin McMillen"); exif.SetValue(ExifTag.Software, "Totte"); exif.SetValue(ExifTag.Rating, (ushort) Rating); DateTime now = DateTime.Now; string datetime = String.Format( "{0:D4}:{1:D2}:{2:D2} {3:D2}:{4:D2}:{5:D2}", now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second); exif.SetValue(ExifTag.DateTime, datetime); image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile); await image.SaveAsync(filename, encoder); } } private XElement? GetXmpRoot(XmpProfile? xmp) { if (xmp == null) { return null; } XDocument? doc = xmp.GetDocument(); if (doc == null) { return null; } return doc.Root; } private int ParseRating(XmpProfile? xmp) { XElement? root = GetXmpRoot(xmp); if (root == null) { return 0; } foreach (XElement elt in root.Descendants()) { if (elt.Name == "{http://ns.adobe.com/xap/1.0/}Rating") { int rating = 0; if (int.TryParse(elt.Value, out rating)) { return rating; } } } return 0; } private XmpProfile? UpdateXmp(XmpProfile? xmp) { if (xmp == null) { return null; } string xmlIn = Encoding.UTF8.GetString(xmp.ToByteArray()); int index = xmlIn.IndexOf(""); if (index == -1) { return xmp; } string xmlOut = xmlIn.Substring(0, index - 1) + Rating.ToString() + xmlIn.Substring(index); return new XmpProfile(Encoding.UTF8.GetBytes(xmlOut)); } // Exif (and other image metadata) reference, from the now-defunct Metadata Working Group: // https://web.archive.org/web/20180919181934/http://www.metadataworkinggroup.org/pdf/mwg_guidance.pdf // // Specifically: // // In general, date/time metadata is being used to describe the following scenarios: // * Date/time original specifies when a photo was taken // * Date/time digitized specifies when an image was digitized // * Date/time modified specifies when a file was modified by the user // // Original Date/Time – Creation date of the intellectual content (e.g. the photograph), rather than the creation date of the content being shown // Exif DateTimeOriginal (36867, 0x9003) and SubSecTimeOriginal (37521, 0x9291) // IPTC DateCreated (IIM 2:55, 0x0237) and TimeCreated (IIM 2:60, 0x023C) // XMP (photoshop:DateCreated) // // Digitized Date/Time – Creation date of the digital representation // Exif DateTimeDigitized (36868, 0x9004) and SubSecTimeDigitized (37522, 0x9292) // IPTC DigitalCreationDate (IIM 2:62, 0x023E) and DigitalCreationTime (IIM 2:63, 0x023F) // XMP (xmp:CreateDate) // // Modification Date/Time – Modification date of the digital image file // Exif DateTime (306, 0x132) and SubSecTime (37520, 0x9290) // XMP (xmp:ModifyDate) // // See also: https://exiftool.org/TagNames/EXIF.html private void ParseExif(ExifProfile? exifs) { if (exifs == null) { return; } IExifValue? orientation; if (exifs.TryGetValue(ExifTag.Orientation, out orientation)) { Orientation = orientation.Value; } 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.Numerator % r.Denominator == 0) { FNumber = $"f/{r.Numerator / r.Denominator}"; } else { int fTimesTen = (int) Math.Round(10f * r.Numerator / r.Denominator); FNumber = $"f/{fTimesTen / 10}.{fTimesTen % 10}"; } } // FIXME: could also show ExposureBiasValue, ExposureMode, ExposureProgram? 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]}"; } } } // FIXME: there is also a SubSecTimeOriginal tag we could use to get fractional seconds. // FIXME: I think the iPhone stores time in UTC but other cameras report it in local time. IExifValue? dateTimeOriginal; if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out dateTimeOriginal)) { DateTime date; if (DateTime.TryParseExact( dateTimeOriginal.Value ?? "", "yyyy:MM:dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out date)) { DateTimeOriginal = date; } else { Console.WriteLine($"*** WARNING: unexpected DateTimeOriginal value: {dateTimeOriginal.Value}"); } } } public Texture Texture() { LastTouch = touchCounter++; if (texture == placeholder && image != null) { // The texture needs to be created on the GL thread, so we instantiate // it here (since this is called from OnRenderFrame), as long as the // image is ready to go. texture = new Texture(image); image.Dispose(); image = null; Loaded = true; } return texture; } public string Description() { string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss"); string shootingInfo = $"{date} {FocalLength}, {FNumber} at {ExposureTime}, {IsoSpeed}"; return String.Format("{0,-60} {1,-50} {2}", shootingInfo, $"{CameraModel} {LensModel}", Filename); } } public class Texture : IDisposable { public int Handle; public Vector2i Size; private static int maxHandle = -1; private bool disposedValue = false; 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); } 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(1024, 768); private static CameraInfo activeCamera = CameraInfo.CANON_EOS_R6M2; 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 numThumbnails = Math.Max(WindowSize.Y / 100, 1); int thumbnailHeight = WindowSize.Y / numThumbnails; int thumbnailWidth = (int) 1.0 * thumbnailHeight * activeCamera.Resolution.X / activeCamera.Resolution.Y; Console.WriteLine($"thumbnail size: {thumbnailWidth} x {thumbnailHeight}"); 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); 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); } 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/ public static void RotateImageFromExif(Image image, ushort orientation) { if (orientation <= 1) { return; } // FIXME: I'm not convinced that all of these are correct, especially the // cases that involve flipping (because whether you're flipping before or // after rotation matters.). 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) { 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 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))); 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 = new(); 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(); int photoIndex = 0; int ribbonIndex = 0; Vector2i mouseDragStart; Vector2i mouseDragEnd; Shader shader = new(); Matrix4 projection; float zoomLevel = 0f; protected override void OnUpdateFrame(FrameEventArgs e) { base.OnUpdateFrame(e); KeyboardState input = KeyboardState; 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(); } int lastPhotoIndex = photoIndex; Vector2i 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 (geometry.PhotoBox.ContainsInclusive(mousePosition)) { mouseDragStart = mousePosition; } } if (MouseState.IsButtonDown(MouseButton.Button1)) { if (geometry.PhotoBox.ContainsInclusive(mousePosition)) { // FIXME: really this should be clipped to the active photo's drawable area, not the whole photobox. mouseDragEnd = mousePosition; } } if (input.IsKeyPressed(Keys.Escape)) { mouseDragStart = new(-1, -1); mouseDragEnd = new(-1, -1); } 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 += 5; } if (input.IsKeyPressed(Keys.PageUp)) { photoIndex -= 5; } if (input.IsKeyPressed(Keys.P) && altIsDown) { ExportPhotos(); } // Make sure the photoIndex is actually valid. if (photos.Count == 0) { photoIndex = 0; } else { photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1); } if (photoIndex != lastPhotoIndex) { mouseDragStart = new(-1, -1); mouseDragEnd = new(-1, -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)) { 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; } } 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); } 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(@"G:\DCIM\100EOSR6\"); // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\07\31"); // 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); photos = allPhotos; } 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. // FIXME: keep around thumbnail-sized textures? lock (loadedImagesLock) { while (loadedImages.Count > 100) { 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}"); // TODO: we have to free textures on the GL thread, but could we do that async'ly to keep the UI responsive? 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 - 30); int maxLoadedImage = Math.Min(photoIndex + 30, 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(); }); } } // To find the JPEG compression level of a file from the command line: // $ identify -verbose image.jpg | grep Quality: // FIXME: don't ExportPhotos() if another export is already active. // FIXME: show a progress bar or something. private async void ExportPhotos() { JpegEncoder encoder = new JpegEncoder() { Quality = 100 }; string outputRoot = @"c:\users\colin\desktop\totte-output"; // string outputRoot = @"c:\users\colin\pictures\photos"; foreach (Photo p in photos) { await Task.Run( () => { p.SaveAsJpegAsync(outputRoot, encoder); }); } } 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); } DrawCropBox(); SwapBuffers(); } void DrawCropBox() { Vector2i start = mouseDragStart; Vector2i end = mouseDragEnd; end.Y = Math.Min(end.Y, start.Y + (end.X - start.X) * 4 / 6); end.X = start.X + (end.Y - start.Y) * 6 / 4; 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); int area = (right - left) * (bottom - top); if (area < 100) { return; } 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, top, right, bottom), 1, Color4.White); DrawBox(new Box2i(left - 1, top - 1 , right + 1, bottom + 1), 1, Color4.Black); DrawBox(new Box2i(left - 2, top - 2 , right + 2, bottom + 2), 1, Color4.White); 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 DrawPhotos() { 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); 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 < 5; i++) { Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY; DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y); } // 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.Texture(), 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 = 2; DrawFilledBox(geometry.StatusBox, Color4.Black); DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count), geometry.StatusBox.Min.X + 72, geometry.StatusBox.Min.Y + statusPadding); DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 160, geometry.StatusBox.Min.Y + statusPadding); DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, geometry.StatusBox.Min.Y + statusPadding); if (activePhoto.Loaded) { DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Min.X, geometry.StatusBox.Min.Y + statusPadding); } } void DrawTexture(Texture texture, int x, int y) { DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y)); } 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 DrawHorizontalLine(int left, int top, int right, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color); } void DrawVerticalLine(int left, int top, int bottom, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color); } 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 DrawFilledBox(Box2i box, Color4 color) { DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color); } 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) { // 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.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(); } } }