You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1106 lines
37 KiB

1 year ago
11 months ago
1 year ago
11 months ago
1 year ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
11 months ago
1 year ago
1 year ago
11 months ago
1 year ago
1 year ago
11 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
11 months ago
11 months ago
11 months ago
11 months ago
1 year ago
11 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
10 months ago
10 months ago
10 months ago
1 year ago
1 year ago
10 months ago
1 year ago
11 months ago
1 year ago
1 year ago
11 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
11 months ago
1 year ago
1 year ago
1 year ago
11 months ago
1 year ago
1 year ago
  1. using OpenTK.Graphics.OpenGL4;
  2. using OpenTK.Mathematics;
  3. using OpenTK.Windowing.Common;
  4. using OpenTK.Windowing.Common.Input;
  5. using OpenTK.Windowing.Desktop;
  6. using OpenTK.Windowing.GraphicsLibraryFramework;
  7. // https://docs.sixlabors.com/api/ImageSharp/SixLabors.ImageSharp.Image.html
  8. using SixLabors.Fonts;
  9. using SixLabors.ImageSharp.Drawing.Processing;
  10. using SixLabors.ImageSharp.Drawing;
  11. using SixLabors.ImageSharp.Formats.Jpeg;
  12. using System;
  13. using System.Diagnostics;
  14. namespace SemiColinGames;
  15. public class FpsCounter {
  16. private readonly int[] frameTimes = new int[30];
  17. private double fps = 0;
  18. private int idx = 0;
  19. public int Fps {
  20. get => (int) Math.Ceiling(fps);
  21. }
  22. public void Update() {
  23. var now = Environment.TickCount; // ms
  24. if (frameTimes[idx] != 0) {
  25. var timeElapsed = now - frameTimes[idx];
  26. fps = 1000.0 * frameTimes.Length / timeElapsed;
  27. }
  28. frameTimes[idx] = now;
  29. idx = (idx + 1) % frameTimes.Length;
  30. }
  31. }
  32. public class CameraInfo {
  33. public static float AspectRatio = 6000f / 4000f;
  34. }
  35. public enum ToolStatus {
  36. Active,
  37. Done,
  38. Canceled
  39. }
  40. public class Transform {
  41. // FIXME: move scale and offset into Photo itself?
  42. float activeScale;
  43. Vector2i activeOffset;
  44. Vector2i photoSize;
  45. public Transform(float scale, Vector2i offset, Vector2i photoSize) {
  46. activeScale = scale;
  47. activeOffset = offset;
  48. this.photoSize = photoSize;
  49. }
  50. public Vector2i ScreenToImageDelta(int x, int y) {
  51. return new((int) (x / activeScale), (int) (y / activeScale));
  52. }
  53. public Vector2i ScreenToImage(int x, int y) {
  54. int rx = (int) ((x - activeOffset.X) / activeScale);
  55. int ry = (int) ((y - activeOffset.Y) / activeScale);
  56. rx = Math.Clamp(rx, 0, photoSize.X);
  57. ry = Math.Clamp(ry, 0, photoSize.Y);
  58. return new(rx, ry);
  59. }
  60. public Vector2i ScreenToImage(Vector2i position) {
  61. return ScreenToImage(position.X, position.Y);
  62. }
  63. public Vector2i ImageToScreen(int x, int y) {
  64. int rx = (int) ((x * activeScale) + activeOffset.X);
  65. int ry = (int) ((y * activeScale) + activeOffset.Y);
  66. return new(rx, ry);
  67. }
  68. public Vector2i ImageToScreen(Vector2i position) {
  69. return ImageToScreen(position.X, position.Y);
  70. }
  71. }
  72. public interface ITool {
  73. ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game);
  74. string Status();
  75. void Draw(UiGeometry geometry, Game game);
  76. }
  77. public class ViewTool : ITool {
  78. public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game) {
  79. return ToolStatus.Active;
  80. }
  81. public string Status() {
  82. return "";
  83. }
  84. public void Draw(UiGeometry geometry, Game game) {
  85. }
  86. }
  87. public class CropTool : ITool {
  88. Photo photo;
  89. Vector2i mouseDragStart;
  90. Vector2i mouseDragEnd;
  91. bool dragging;
  92. string status = "";
  93. public CropTool(Photo photo) {
  94. this.photo = photo;
  95. mouseDragStart = new(photo.CropRectangle.Left, photo.CropRectangle.Top);
  96. mouseDragEnd = new(photo.CropRectangle.Right, photo.CropRectangle.Bottom);
  97. }
  98. public ToolStatus HandleInput(KeyboardState input, MouseState mouse, Transform transform, Game game) {
  99. Vector2i mousePosition = (Vector2i) mouse.Position;
  100. Vector2i imagePosition = transform.ScreenToImage(mousePosition);
  101. bool mouseInRectangle = photo.CropRectangle.Contains(imagePosition.X, imagePosition.Y);
  102. if (mouse.IsButtonPressed(MouseButton.Button1)) {
  103. dragging = mouseInRectangle;
  104. }
  105. game.Cursor = mouseInRectangle ? MouseCursor.Default : MouseCursor.Crosshair;
  106. if (!dragging) {
  107. if (mouse.IsButtonPressed(MouseButton.Button1)) {
  108. mouseDragStart = imagePosition;
  109. }
  110. if (mouse.IsButtonDown(MouseButton.Button1)) {
  111. mouseDragEnd = imagePosition;
  112. }
  113. var (left, right, top, bottom) = GetCrop();
  114. if (left != right && top != bottom) {
  115. photo.CropRectangle = Rectangle.FromLTRB(left, top, right, bottom);
  116. } else {
  117. photo.CropRectangle = Rectangle.Empty;
  118. }
  119. } else {
  120. if (mouse.IsButtonDown(MouseButton.Button1)) {
  121. Vector2 delta = mouse.Delta;
  122. Vector2i imageDelta = transform.ScreenToImageDelta((int) delta.X, (int) delta.Y);
  123. photo.CropRectangle.Offset(imageDelta.X, imageDelta.Y);
  124. if (photo.CropRectangle.Left < 0) {
  125. photo.CropRectangle.Offset(-photo.CropRectangle.Left, 0);
  126. }
  127. if (photo.CropRectangle.Right > photo.Size.X) {
  128. int overshoot = photo.CropRectangle.Right - photo.Size.X;
  129. photo.CropRectangle.Offset(-overshoot, 0);
  130. }
  131. if (photo.CropRectangle.Top < 0) {
  132. photo.CropRectangle.Offset(0, -photo.CropRectangle.Top);
  133. }
  134. if (photo.CropRectangle.Bottom > photo.Size.Y) {
  135. int overshoot = photo.CropRectangle.Bottom - photo.Size.Y;
  136. photo.CropRectangle.Offset(0, -overshoot);
  137. }
  138. }
  139. }
  140. Rectangle r = photo.CropRectangle;
  141. status = $"({r.Left}, {r.Top}, {r.Right}, {r.Bottom}) {r.Width}x{r.Height}";
  142. if (input.IsKeyPressed(Keys.Enter)) {
  143. game.Cursor = MouseCursor.Default;
  144. return ToolStatus.Done;
  145. }
  146. if (input.IsKeyPressed(Keys.Escape)) {
  147. game.Cursor = MouseCursor.Default;
  148. photo.CropRectangle = Rectangle.Empty;
  149. return ToolStatus.Canceled;
  150. }
  151. return ToolStatus.Active;
  152. }
  153. // left, right, top, bottom
  154. (int, int, int, int) GetCrop() {
  155. // FIXME: allow for unconstrained crop, 1:1, etc.
  156. float aspectRatio = 1f * photo.Size.X / photo.Size.Y;
  157. Vector2i start = mouseDragStart;
  158. Vector2i end = mouseDragEnd;
  159. int width = Math.Abs(end.X - start.X);
  160. int height = Math.Abs(end.Y - start.Y);
  161. int heightChange = Math.Min(height, (int) (width / aspectRatio));
  162. int widthChange = (int) (heightChange * aspectRatio);
  163. if (end.X < start.X) {
  164. widthChange *= -1;
  165. }
  166. if (end.Y < start.Y) {
  167. heightChange *= -1;
  168. }
  169. end.Y = start.Y + heightChange;
  170. end.X = start.X + widthChange;
  171. int left = Math.Min(start.X, end.X);
  172. int right = Math.Max(start.X, end.X);
  173. int top = Math.Min(start.Y, end.Y);
  174. int bottom = Math.Max(start.Y, end.Y);
  175. return (left, right, top, bottom);
  176. }
  177. public void Draw(UiGeometry geometry, Game game) {
  178. }
  179. public string Status() {
  180. return "[crop] " + status;
  181. }
  182. }
  183. public class UiGeometry {
  184. public static Vector2i MIN_WINDOW_SIZE = new(1024, 768);
  185. public readonly Vector2i WindowSize;
  186. public readonly Vector2i ThumbnailSize;
  187. public readonly Box2i ThumbnailBox;
  188. public readonly List<Box2i> ThumbnailBoxes = new();
  189. public readonly List<Box2i> StarBoxes = new();
  190. public readonly Box2i PhotoBox;
  191. public readonly Box2i StatusBox;
  192. public UiGeometry() : this(MIN_WINDOW_SIZE, 0) {}
  193. public UiGeometry(Vector2i windowSize, int starSize) {
  194. WindowSize = windowSize;
  195. int numThumbnails = Math.Max(WindowSize.Y / 100, 1);
  196. int thumbnailHeight = WindowSize.Y / numThumbnails;
  197. int thumbnailWidth = (int) (1.0 * thumbnailHeight * CameraInfo.AspectRatio);
  198. ThumbnailSize = new(thumbnailWidth, thumbnailHeight);
  199. Console.WriteLine($"thumbnail size: {thumbnailWidth}x{thumbnailHeight}");
  200. for (int i = 0; i < numThumbnails; i++) {
  201. Box2i box = Util.MakeBox(WindowSize.X - thumbnailWidth, i * thumbnailHeight,
  202. thumbnailWidth, thumbnailHeight);
  203. ThumbnailBoxes.Add(box);
  204. }
  205. int statusBoxHeight = 40;
  206. int statusBoxPadding = 4;
  207. PhotoBox = new Box2i(
  208. 0, 0, WindowSize.X - thumbnailWidth, WindowSize.Y - statusBoxHeight - statusBoxPadding);
  209. StatusBox = new Box2i(
  210. 0, WindowSize.Y - statusBoxHeight, WindowSize.X - thumbnailWidth, WindowSize.Y);
  211. ThumbnailBox = new Box2i(
  212. ThumbnailBoxes[0].Min.X, ThumbnailBoxes[0].Min.Y, WindowSize.X, WindowSize.Y);
  213. int starSpacing = 10;
  214. int starBoxLeft = (int) (PhotoBox.Center.X - 2.5 * starSize - starSpacing * 2);
  215. for (int i = 0; i < 5; i++) {
  216. Box2i box = Util.MakeBox(
  217. starBoxLeft + i * (starSize + starSpacing), PhotoBox.Max.Y - starSize - 10,
  218. starSize, starSize);
  219. StarBoxes.Add(box);
  220. }
  221. }
  222. }
  223. public static class Util {
  224. public const float PI = (float) Math.PI;
  225. public static int Lerp(int start, int end, double fraction) {
  226. return start + (int) ((end - start) * fraction);
  227. }
  228. public static Box2i MakeBox(int left, int top, int width, int height) {
  229. return new Box2i(left, top, left + width, top + height);
  230. }
  231. public static Image<Rgba32> MakeImage(float width, float height) {
  232. return new((int) Math.Ceiling(width), (int) Math.Ceiling(height));
  233. }
  234. // https://sirv.com/help/articles/rotate-photos-to-be-upright/
  235. public static void RotateImageFromExif(Image<Rgba32> image, ushort orientation) {
  236. if (orientation <= 1) {
  237. return;
  238. }
  239. var operations = new Dictionary<ushort, (RotateMode, FlipMode)> {
  240. { 2, (RotateMode.None, FlipMode.Horizontal) },
  241. { 3, (RotateMode.Rotate180, FlipMode.None) },
  242. { 4, (RotateMode.None, FlipMode.Vertical) },
  243. { 5, (RotateMode.Rotate90, FlipMode.Vertical) },
  244. { 6, (RotateMode.Rotate90, FlipMode.None) },
  245. { 7, (RotateMode.Rotate270, FlipMode.Vertical) },
  246. { 8, (RotateMode.Rotate270, FlipMode.None) },
  247. };
  248. var (rotate, flip) = operations[orientation];
  249. image.Mutate(x => x.RotateFlip(rotate, flip));
  250. }
  251. public static Texture RenderText(string text) {
  252. return RenderText(text, 16);
  253. }
  254. public static Texture RenderText(string text, int size) {
  255. // Make sure that 0-length text doesn't end up as a 0-size texture, which
  256. // might cause problems.
  257. if (text.Length == 0) {
  258. text = " ";
  259. }
  260. Font font = SystemFonts.CreateFont("Consolas", size, FontStyle.Bold);
  261. TextOptions options = new(font);
  262. FontRectangle rect = TextMeasurer.Measure(text, new TextOptions(font));
  263. Image<Rgba32> image = MakeImage(rect.Width, rect.Height);
  264. IBrush brush = Brushes.Solid(Color.White);
  265. image.Mutate(x => x.DrawText(options, text, brush));
  266. Texture texture = new Texture(image);
  267. image.Dispose();
  268. return texture;
  269. }
  270. // FIXME: make a real icon stored as a PNG...
  271. public static OpenTK.Windowing.Common.Input.Image[] RenderAppIcon() {
  272. int size = 64;
  273. Font font = SystemFonts.CreateFont("MS Mincho", size, FontStyle.Bold);
  274. TextOptions options = new(font);
  275. Image<Rgba32> image = MakeImage(size, size);
  276. IBrush brush = Brushes.Solid(Color.Black);
  277. image.Mutate(x => x.DrawText(options, "撮", brush));
  278. byte[] pixelBytes = new byte[size * size * 4];
  279. image.CopyPixelDataTo(pixelBytes);
  280. image.Dispose();
  281. OpenTK.Windowing.Common.Input.Image opentkImage = new(size, size, pixelBytes);
  282. return new OpenTK.Windowing.Common.Input.Image[]{ opentkImage };
  283. }
  284. public static Texture RenderStar(float radius, bool filled) {
  285. IPath path = new Star(x: radius, y: radius + 1, prongs: 5,
  286. innerRadii: radius * 0.4f, outerRadii: radius, angle: Util.PI);
  287. // We add a little bit to the width & height because the reported
  288. // path.Bounds are often a little tighter than they should be & a couple
  289. // pixels end up obviously missing...
  290. Image<Rgba32> image = MakeImage(path.Bounds.Width + 2, path.Bounds.Height + 2);
  291. IBrush brush = Brushes.Solid(Color.White);
  292. IPen white = Pens.Solid(Color.White, 1.5f);
  293. IPen black = Pens.Solid(Color.Black, 3f);
  294. image.Mutate(x => x.Draw(black, path));
  295. if (filled) {
  296. image.Mutate(x => x.Fill(brush, path));
  297. }
  298. image.Mutate(x => x.Draw(white, path));
  299. Texture texture = new Texture(image);
  300. image.Dispose();
  301. return texture;
  302. }
  303. }
  304. public class Toast {
  305. private string message = "";
  306. private double time;
  307. private double expiryTime;
  308. public void Set(string message) {
  309. this.message = message;
  310. this.expiryTime = time + 5.0;
  311. }
  312. public string Get() {
  313. return message;
  314. }
  315. public void Update(double elapsed) {
  316. time += elapsed;
  317. if (time > expiryTime) {
  318. message = "";
  319. }
  320. }
  321. }
  322. public class Game : GameWindow {
  323. public Game(GameWindowSettings gwSettings, NativeWindowSettings nwSettings) :
  324. base(gwSettings, nwSettings) {
  325. activeTool = viewTool;
  326. geometry = new UiGeometry(nwSettings.Size, STAR_FILLED.Size.X);
  327. }
  328. private static string outputRoot = @"c:\users\colin\desktop\totte-output";
  329. // private static string outputRoot = @"c:\users\colin\pictures\photos";
  330. private static Texture TEXTURE_WHITE = new(new Image<Rgba32>(1, 1, new Rgba32(255, 255, 255)));
  331. private static Texture TEXTURE_BLACK = new(new Image<Rgba32>(1, 1, new Rgba32(0, 0, 0)));
  332. private static Texture STAR_FILLED = Util.RenderStar(20, true);
  333. private static Texture STAR_EMPTY = Util.RenderStar(20, false);
  334. private static Texture STAR_SMALL = Util.RenderStar(6, true);
  335. UiGeometry geometry;
  336. FpsCounter fpsCounter = new();
  337. // Four points, each consisting of (x, y, z, tex_x, tex_y).
  338. float[] vertices = new float[20];
  339. // Indices to draw a rectangle from two triangles.
  340. uint[] indices = {
  341. 0, 1, 3, // first triangle
  342. 1, 2, 3 // second triangle
  343. };
  344. int VertexBufferObject;
  345. int ElementBufferObject;
  346. int VertexArrayObject;
  347. List<Photo> allPhotos = new();
  348. List<Photo> photos = new();
  349. HashSet<Photo> loadedImages = new();
  350. HashSet<Photo> loadingImages = new();
  351. readonly object loadedImagesLock = new();
  352. readonly ViewTool viewTool = new ViewTool();
  353. Toast toast = new();
  354. ITool activeTool;
  355. int photoIndex = 0;
  356. int ribbonIndex = 0;
  357. Vector2i mousePosition;
  358. float activeScale = 1f;
  359. Vector2i activeOffset;
  360. Transform transform = new(1f, Vector2i.Zero, Vector2i.Zero);
  361. Shader shader = new();
  362. Matrix4 projection;
  363. float zoomLevel = 0f;
  364. double timeSinceEvent = 0;
  365. // Variables that are protected by locks:
  366. readonly object numThumbnailsLoadedLock = new();
  367. int numThumbnailsLoaded = 0;
  368. readonly object exportPhotosLock = new(); // locks the entire ExportPhotos() function.
  369. int numPhotosToExport = 0;
  370. readonly object numPhotosExportedLock = new();
  371. int numPhotosExported = 0;
  372. protected override void OnUpdateFrame(FrameEventArgs e) {
  373. base.OnUpdateFrame(e);
  374. toast.Update(e.Time);
  375. KeyboardState input = KeyboardState;
  376. bool mouseInWindow = ClientRectangle.ContainsInclusive((Vector2i) MouseState.Position);
  377. if (input.IsAnyKeyDown ||
  378. MouseState.IsAnyButtonDown ||
  379. (mouseInWindow && MouseState.Delta != Vector2i.Zero)) {
  380. timeSinceEvent = 0;
  381. } else {
  382. timeSinceEvent += e.Time;
  383. }
  384. if (IsFocused && timeSinceEvent < 1) {
  385. RenderFrequency = 30;
  386. UpdateFrequency = 30;
  387. } else {
  388. RenderFrequency = 5;
  389. UpdateFrequency = 5;
  390. }
  391. Photo previousPhoto = photos[photoIndex];
  392. bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift);
  393. bool altIsDown = input.IsKeyDown(Keys.LeftAlt) || input.IsKeyDown(Keys.RightAlt);
  394. bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl);
  395. // FIXME: add a confirm dialog before closing. (Also for the window-close button.)
  396. // FIXME: don't quit if there's pending file-write operations.
  397. // Close when Ctrl-Q is pressed.
  398. if (input.IsKeyPressed(Keys.Q) && ctrlIsDown) {
  399. Close();
  400. }
  401. mousePosition = (Vector2i) MouseState.Position;
  402. // Look for mouse clicks on thumbnails or stars.
  403. //
  404. // Note that we don't bounds-check photoIndex until after all the possible
  405. // inputs that might affect it. That simplifies this logic significantly.
  406. if (MouseState.IsButtonPressed(MouseButton.Button1)) {
  407. for (int i = 0; i < geometry.StarBoxes.Count; i++) {
  408. if (geometry.StarBoxes[i].ContainsInclusive(mousePosition)) {
  409. photos[photoIndex].Rating = i + 1;
  410. }
  411. }
  412. for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
  413. if (geometry.ThumbnailBoxes[i].ContainsInclusive(mousePosition)) {
  414. photoIndex = ribbonIndex + i;
  415. }
  416. }
  417. }
  418. if (MouseState.IsButtonPressed(MouseButton.Button4)) {
  419. photoIndex--;
  420. }
  421. if (MouseState.IsButtonPressed(MouseButton.Button5)) {
  422. photoIndex++;
  423. }
  424. if (MouseState.ScrollDelta.Y < 0) {
  425. photoIndex++;
  426. }
  427. if (MouseState.ScrollDelta.Y > 0) {
  428. photoIndex--;
  429. }
  430. if (input.IsKeyPressed(Keys.Down)) {
  431. photoIndex++;
  432. }
  433. if (input.IsKeyPressed(Keys.Up)) {
  434. photoIndex--;
  435. }
  436. if (input.IsKeyPressed(Keys.Home)) {
  437. photoIndex = 0;
  438. }
  439. if (input.IsKeyPressed(Keys.End)) {
  440. photoIndex = photos.Count - 1;
  441. }
  442. if (input.IsKeyPressed(Keys.PageDown)) {
  443. photoIndex += 5;
  444. }
  445. if (input.IsKeyPressed(Keys.PageUp)) {
  446. photoIndex -= 5;
  447. }
  448. if (input.IsKeyPressed(Keys.P) && ctrlIsDown) {
  449. ExportPhotos();
  450. }
  451. // Make sure the photoIndex is actually valid.
  452. if (photos.Count == 0) {
  453. photoIndex = 0;
  454. } else {
  455. photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1);
  456. }
  457. // Handle presses of the "rating" keys -- 0-5 and `.
  458. // A normal press just sets the rating of the current photo.
  459. // If the user is holding "shift", we instead filter to only show photos
  460. // of that rating or higher.
  461. int rating = -1;
  462. if (input.IsKeyPressed(Keys.D0) || input.IsKeyPressed(Keys.GraveAccent)) {
  463. rating = 0;
  464. }
  465. if (input.IsKeyPressed(Keys.D1)) {
  466. rating = 1;
  467. }
  468. if (input.IsKeyPressed(Keys.D2)) {
  469. rating = 2;
  470. }
  471. if (input.IsKeyPressed(Keys.D3)) {
  472. rating = 3;
  473. }
  474. if (input.IsKeyPressed(Keys.D4)) {
  475. rating = 4;
  476. }
  477. if (input.IsKeyPressed(Keys.D5)) {
  478. rating = 5;
  479. }
  480. if (rating >= 0) {
  481. if (shiftIsDown) {
  482. FilterByRating(rating);
  483. } else {
  484. if (photos.Count > 0) {
  485. photos[photoIndex].Rating = rating;
  486. }
  487. }
  488. }
  489. if (input.IsKeyPressed(Keys.Q)) {
  490. zoomLevel = 0f;
  491. }
  492. if (input.IsKeyPressed(Keys.W)) {
  493. zoomLevel = 1f;
  494. }
  495. if (input.IsKeyPressed(Keys.E)) {
  496. zoomLevel = 2f;
  497. }
  498. if (input.IsKeyPressed(Keys.R)) {
  499. zoomLevel = 4f;
  500. }
  501. if (input.IsKeyPressed(Keys.T)) {
  502. zoomLevel = 8f;
  503. }
  504. if (input.IsKeyPressed(Keys.Y)) {
  505. zoomLevel = 16f;
  506. }
  507. // Handle tool switching.
  508. if (photos[photoIndex] != previousPhoto) {
  509. activeTool = viewTool;
  510. }
  511. if (activeTool == viewTool) {
  512. if (input.IsKeyPressed(Keys.C)) {
  513. activeTool = new CropTool(photos[photoIndex]);
  514. }
  515. }
  516. // Delegate input to the active tool.
  517. ToolStatus status = activeTool.HandleInput(KeyboardState, MouseState, transform, this);
  518. // Change back to the default tool if the active tool is done.
  519. if (status != ToolStatus.Active) {
  520. activeTool = viewTool;
  521. }
  522. }
  523. void FilterByRating(int rating) {
  524. Console.WriteLine("filter to " + rating);
  525. Photo previouslyActive = photos.Count > 0 ? photos[photoIndex] : allPhotos[0];
  526. photos = allPhotos.Where(p => p.Rating >= rating).ToList();
  527. // Move photoIndex to wherever the previously active photo was, or if it
  528. // was filtered out, to whichever unfiltered photo comes before it. This
  529. // is O(n) in the length of allPhotos, but how bad can it be? :)
  530. photoIndex = -1;
  531. for (int i = 0; i < allPhotos.Count; i++) {
  532. Photo candidate = allPhotos[i];
  533. if (candidate.Rating >= rating) {
  534. photoIndex++;
  535. }
  536. if (candidate == previouslyActive) {
  537. break;
  538. }
  539. }
  540. photoIndex = Math.Max(0, photoIndex);
  541. }
  542. // FIXME: switch to immediate mode??
  543. // https://gamedev.stackexchange.com/questions/198805/opentk-immediate-mode-on-net-core-doesnt-work
  544. // https://www.youtube.com/watch?v=Q23Kf9QEaO4
  545. protected override void OnLoad() {
  546. base.OnLoad();
  547. GL.ClearColor(0f, 0f, 0f, 1f);
  548. GL.Enable(EnableCap.Blend);
  549. GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
  550. VertexArrayObject = GL.GenVertexArray();
  551. GL.BindVertexArray(VertexArrayObject);
  552. VertexBufferObject = GL.GenBuffer();
  553. ElementBufferObject = GL.GenBuffer();
  554. GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
  555. GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float),
  556. vertices, BufferUsageHint.DynamicDraw);
  557. GL.BindBuffer(BufferTarget.ElementArrayBuffer, ElementBufferObject);
  558. GL.BufferData(BufferTarget.ElementArrayBuffer, indices.Length * sizeof(uint),
  559. indices, BufferUsageHint.DynamicDraw);
  560. shader.Init();
  561. shader.Use();
  562. // Because there's 5 floats between the start of the first vertex and the start of the second,
  563. // the stride is 5 * sizeof(float).
  564. // This will now pass the new vertex array to the buffer.
  565. var vertexLocation = shader.GetAttribLocation("aPosition");
  566. GL.EnableVertexAttribArray(vertexLocation);
  567. GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float,
  568. false, 5 * sizeof(float), 0);
  569. // Next, we also setup texture coordinates. It works in much the same way.
  570. // We add an offset of 3, since the texture coordinates comes after the position data.
  571. // We also change the amount of data to 2 because there's only 2 floats for texture coordinates.
  572. var texCoordLocation = shader.GetAttribLocation("aTexCoord");
  573. GL.EnableVertexAttribArray(texCoordLocation);
  574. GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float,
  575. false, 5 * sizeof(float), 3 * sizeof(float));
  576. // Load photos from a directory.
  577. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\photos-test\");
  578. // string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\14\");
  579. // string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\23\");
  580. // string[] files = Directory.GetFiles(@"G:\DCIM\100EOSR6\");
  581. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\08\29");
  582. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\import");
  583. // string[] files = Directory.GetFiles(@"C:\Users\colin\Pictures\photos\2018\06\23");
  584. // string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\Germany all\104D7000");
  585. string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\many-birds\");
  586. for (int i = 0; i < files.Count(); i++) {
  587. string file = files[i];
  588. if (file.ToLower().EndsWith(".jpg")) {
  589. Photo photo = new Photo(file, TEXTURE_BLACK);
  590. allPhotos.Add(photo);
  591. }
  592. }
  593. allPhotos.Sort(ComparePhotosByDate);
  594. // Fix up photos with missing GPS. We start at the end and work our way
  595. // backwards, because if one photo is missing GPS, it's probably because
  596. // the camera was turned off for a while, and whichever photo *after* it
  597. // has GPS data is probably more accurate.
  598. GpsInfo? lastGps = null;
  599. for (int i = allPhotos.Count - 1; i >= 0; i--) {
  600. Photo p = allPhotos[i];
  601. if (p.Gps == null) {
  602. Console.WriteLine("fixing GPS for " + p.Filename);
  603. p.Gps = lastGps;
  604. } else {
  605. lastGps = p.Gps;
  606. }
  607. }
  608. photos = allPhotos;
  609. LoadThumbnailsAsync();
  610. }
  611. private static int ComparePhotosByDate(Photo x, Photo y) {
  612. int compare = x.DateTimeOriginal.CompareTo(y.DateTimeOriginal);
  613. if (compare != 0) {
  614. return compare;
  615. }
  616. // If the photos have the same seconds value, sort by filename
  617. // (since cameras usually increment the filename for successive shots.)
  618. return x.Filename.CompareTo(y.Filename);
  619. }
  620. protected override void OnUnload() {
  621. base.OnUnload();
  622. }
  623. private void UnloadImages() {
  624. // Unload images that haven't been touched in a while.
  625. lock (loadedImagesLock) {
  626. while (loadedImages.Count > 30) {
  627. long earliestTime = long.MaxValue;
  628. Photo? earliest = null;
  629. foreach (Photo photo in loadedImages) {
  630. if (photo.LastTouch < earliestTime) {
  631. earliest = photo;
  632. earliestTime = photo.LastTouch;
  633. }
  634. }
  635. if (earliest != null) {
  636. Console.WriteLine($"loadedImages.Count: {loadedImages.Count}, " +
  637. $"evicting {earliest.Filename} @ {earliestTime}");
  638. earliest.Unload();
  639. loadedImages.Remove(earliest);
  640. }
  641. }
  642. }
  643. }
  644. private async void LoadImagesAsync() {
  645. foreach (Photo p in loadingImages) {
  646. if (p.Loaded) {
  647. lock (loadedImagesLock) {
  648. loadedImages.Add(p);
  649. loadingImages.Remove(p);
  650. }
  651. }
  652. }
  653. // Start loading any images that are in our window but not yet loaded.
  654. int minLoadedImage = Math.Max(0, photoIndex - 10);
  655. int maxLoadedImage = Math.Min(photoIndex + 10, photos.Count - 1);
  656. List<Photo> toLoad = new();
  657. for (int i = minLoadedImage; i <= maxLoadedImage; i++) {
  658. lock (loadedImagesLock) {
  659. if (!loadedImages.Contains(photos[i]) && !loadingImages.Contains(photos[i])) {
  660. Console.WriteLine("loading " + i);
  661. loadingImages.Add(photos[i]);
  662. toLoad.Add(photos[i]);
  663. }
  664. }
  665. }
  666. foreach (Photo p in toLoad) {
  667. await Task.Run( () => { p.LoadAsync(p.Size); });
  668. }
  669. }
  670. private async void LoadThumbnailsAsync() {
  671. List<Task> tasks = new();
  672. foreach (Photo p in allPhotos) {
  673. tasks.Add(Task.Run( () => {
  674. p.LoadThumbnailAsync(geometry.ThumbnailSize);
  675. lock (numThumbnailsLoadedLock) {
  676. numThumbnailsLoaded++;
  677. toast.Set($"[{numThumbnailsLoaded}/{allPhotos.Count}] Loading thumbnails");
  678. }
  679. }));
  680. }
  681. await Task.WhenAll(tasks).ContinueWith(t => { toast.Set("Loading thumbnails: done!"); });
  682. }
  683. // To find the JPEG compression level of a file from the command line:
  684. // $ identify -verbose image.jpg | grep Quality:
  685. private async void ExportPhotos() {
  686. List<Task> tasks = new();
  687. lock (exportPhotosLock) {
  688. // Don't ExportPhotos() if one is already active.
  689. lock (numPhotosExportedLock) {
  690. if (numPhotosToExport > 0 && numPhotosExported != numPhotosToExport) {
  691. Console.WriteLine("ExportPhotos: skipping because another export is already in progress.");
  692. return;
  693. }
  694. }
  695. JpegEncoder encoder = new JpegEncoder() { Quality = 100 };
  696. numPhotosToExport = photos.Count;
  697. numPhotosExported = 0;
  698. foreach (Photo p in photos) {
  699. tasks.Add(Task.Run( () => {
  700. p.SaveAsJpegAsync(outputRoot, encoder);
  701. lock (numPhotosExportedLock) {
  702. numPhotosExported++;
  703. toast.Set($"[{numPhotosExported}/{numPhotosToExport}] Exported {outputRoot}/{p.Filename}");
  704. }
  705. }));
  706. }
  707. }
  708. await Task.WhenAll(tasks).ContinueWith(t => { toast.Set("Exporting photos: done!"); });
  709. }
  710. protected override void OnRenderFrame(FrameEventArgs e) {
  711. base.OnRenderFrame(e);
  712. fpsCounter.Update();
  713. UnloadImages();
  714. LoadImagesAsync();
  715. GL.Clear(ClearBufferMask.ColorBufferBit);
  716. GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
  717. GL.ActiveTexture(TextureUnit.Texture0);
  718. if (photos.Count > 0) {
  719. DrawPhotos();
  720. } else {
  721. DrawText("No photos found.", 10, 10);
  722. }
  723. activeTool.Draw(geometry, this);
  724. SwapBuffers();
  725. }
  726. void DrawPhotos() {
  727. Photo activePhoto = photos[photoIndex];
  728. Texture active = activePhoto.Texture();
  729. // FIXME: make a function for scaling & centering one box on another.
  730. // FIXME: cropping is fucky because activeScale is using the texture size, not the photo size.
  731. float scaleX = 1f * geometry.PhotoBox.Size.X / active.Size.X;
  732. float scaleY = 1f * geometry.PhotoBox.Size.Y / active.Size.Y;
  733. float scale = Math.Min(scaleX, scaleY);
  734. if (zoomLevel > 0f) {
  735. scale = zoomLevel;
  736. }
  737. activeScale = scale;
  738. Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale);
  739. Vector2i center = (Vector2i) geometry.PhotoBox.Center;
  740. Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2, center.Y - renderSize.Y / 2,
  741. renderSize.X, renderSize.Y);
  742. activeOffset = new(photoBox.Min.X, photoBox.Min.Y);
  743. transform = new Transform(activeScale, activeOffset, activePhoto.Size);
  744. DrawTexture(active, photoBox);
  745. for (int i = 0; i < 5; i++) {
  746. Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY;
  747. DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y);
  748. }
  749. bool cropActive = activeTool is CropTool;
  750. DrawCropRectangle(cropActive);
  751. // Draw thumbnail boxes.
  752. ribbonIndex = Math.Clamp(photoIndex - (geometry.ThumbnailBoxes.Count - 1) / 2,
  753. 0, Math.Max(0, photos.Count - geometry.ThumbnailBoxes.Count));
  754. DrawFilledBox(geometry.ThumbnailBox, Color4.Black);
  755. for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
  756. if (ribbonIndex + i >= photos.Count) {
  757. break;
  758. }
  759. Photo photo = photos[ribbonIndex + i];
  760. Box2i box = geometry.ThumbnailBoxes[i];
  761. DrawTexture(photo.ThumbnailTexture(), box);
  762. for (int j = 0; j < photo.Rating; j++) {
  763. DrawTexture(STAR_SMALL, box.Min.X + 8 + ((STAR_SMALL.Size.X + 2) * j), box.Min.Y + 8);
  764. }
  765. if (ribbonIndex + i == photoIndex) {
  766. DrawBox(box, 5, Color4.Black);
  767. DrawBox(box, 3, Color4.White);
  768. }
  769. }
  770. // Draw status box.
  771. int statusPadding = 2;
  772. DrawFilledBox(geometry.StatusBox, Color4.Black);
  773. // First line.
  774. int y = geometry.StatusBox.Min.Y + statusPadding;
  775. DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count),
  776. geometry.StatusBox.Min.X, y);
  777. DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 88, y);
  778. // Second line.
  779. y += 20;
  780. DrawText(activeTool.Status() + toast.Get(), geometry.StatusBox.Min.X, y);
  781. DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, y);
  782. if (activePhoto.Loaded) {
  783. DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Max.X - 136, y);
  784. }
  785. }
  786. void DrawCropRectangle(bool active) {
  787. Photo activePhoto = photos[photoIndex];
  788. if (activePhoto.CropRectangle == Rectangle.Empty) {
  789. return;
  790. }
  791. Vector2i leftTop = transform.ImageToScreen(activePhoto.CropRectangle.Left,
  792. activePhoto.CropRectangle.Top);
  793. Vector2i rightBottom = transform.ImageToScreen(activePhoto.CropRectangle.Right,
  794. activePhoto.CropRectangle.Bottom);
  795. var (left, top) = leftTop;
  796. var (right, bottom) = rightBottom;
  797. Color4 shadeColor = new Color4(0, 0, 0, 0.75f);
  798. DrawFilledBox(new Box2i(0, 0, left, geometry.PhotoBox.Max.Y), shadeColor);
  799. DrawFilledBox(new Box2i(left, 0, geometry.PhotoBox.Max.X, top), shadeColor);
  800. DrawFilledBox(new Box2i(left, bottom, geometry.PhotoBox.Max.X, geometry.PhotoBox.Max.Y),
  801. shadeColor);
  802. DrawFilledBox(new Box2i(right, top, geometry.PhotoBox.Max.X, bottom), shadeColor);
  803. DrawBox(new Box2i(left - 1, top - 1, right + 1, bottom + 1), 1, Color4.White);
  804. if (active) {
  805. // handles
  806. int handleThickness = 3;
  807. int handleLength = 16;
  808. // top-left
  809. DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
  810. left + handleLength, top), Color4.White);
  811. DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
  812. left, top + handleLength), Color4.White);
  813. // top-right
  814. DrawFilledBox(new Box2i(right - handleLength, top - handleThickness,
  815. right + handleThickness, top), Color4.White);
  816. DrawFilledBox(new Box2i(right, top - handleThickness,
  817. right + handleThickness, top + handleLength), Color4.White);
  818. // bottom-left
  819. DrawFilledBox(new Box2i(left - handleThickness, bottom,
  820. left + handleLength, bottom + handleThickness), Color4.White);
  821. DrawFilledBox(new Box2i(left - handleThickness, bottom - handleLength,
  822. left, bottom + handleThickness), Color4.White);
  823. // bottom-right
  824. DrawFilledBox(new Box2i(right - handleLength, bottom,
  825. right + handleThickness, bottom + handleThickness), Color4.White);
  826. DrawFilledBox(new Box2i(right + handleThickness, bottom - handleLength,
  827. right, bottom + handleThickness), Color4.White);
  828. // thirds
  829. DrawHorizontalLine(left, Util.Lerp(top, bottom, 1.0 / 3), right, Color4.White);
  830. DrawHorizontalLine(left, Util.Lerp(top, bottom, 2.0 / 3), right, Color4.White);
  831. DrawVerticalLine(Util.Lerp(left, right, 1.0 / 3), top, bottom, Color4.White);
  832. DrawVerticalLine(Util.Lerp(left, right, 2.0 / 3), top, bottom, Color4.White);
  833. }
  834. }
  835. public void DrawTexture(Texture texture, int x, int y) {
  836. DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y));
  837. }
  838. public void DrawTexture(Texture texture, Box2i box) {
  839. DrawTexture(texture, box, Color4.White);
  840. }
  841. public void DrawTexture(Texture texture, Box2i box, Color4 color) {
  842. GL.Uniform4(shader.GetUniformLocation("color"), color);
  843. SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y);
  844. GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices,
  845. BufferUsageHint.DynamicDraw);
  846. GL.BindTexture(TextureTarget.Texture2D, texture.Handle);
  847. GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0);
  848. }
  849. public void DrawHorizontalLine(int left, int top, int right, Color4 color) {
  850. DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color);
  851. }
  852. public void DrawVerticalLine(int left, int top, int bottom, Color4 color) {
  853. DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color);
  854. }
  855. public void DrawBox(Box2i box, int thickness, Color4 color) {
  856. DrawTexture(TEXTURE_WHITE,
  857. Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, thickness), color);
  858. DrawTexture(TEXTURE_WHITE,
  859. Util.MakeBox(box.Min.X, box.Min.Y, thickness, box.Size.Y), color);
  860. DrawTexture(TEXTURE_WHITE,
  861. Util.MakeBox(box.Min.X, box.Max.Y - thickness, box.Size.X, thickness), color);
  862. DrawTexture(TEXTURE_WHITE,
  863. Util.MakeBox(box.Max.X - thickness, box.Min.Y, thickness, box.Size.Y), color);
  864. }
  865. public void DrawFilledBox(Box2i box, Color4 color) {
  866. DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color);
  867. }
  868. public void DrawText(string text, int x, int y) {
  869. Texture label = Util.RenderText(text);
  870. DrawTexture(label, x, y);
  871. label.Dispose();
  872. }
  873. protected override void OnResize(ResizeEventArgs e) {
  874. base.OnResize(e);
  875. Console.WriteLine($"OnResize: {e.Width}x{e.Height}");
  876. geometry = new UiGeometry(e.Size, STAR_FILLED.Size.X);
  877. projection = Matrix4.CreateOrthographicOffCenter(0f, e.Width, e.Height, 0f, -1f, 1f);
  878. GL.UniformMatrix4(shader.GetUniformLocation("projection"), true, ref projection);
  879. GL.Viewport(0, 0, e.Width, e.Height);
  880. }
  881. private void SetVertices(float left, float top, float width, float height) {
  882. // top left
  883. vertices[0] = left;
  884. vertices[1] = top;
  885. vertices[2] = 0f;
  886. vertices[3] = 0f;
  887. vertices[4] = 0f;
  888. // top right
  889. vertices[5] = left + width;
  890. vertices[6] = top;
  891. vertices[7] = 0f;
  892. vertices[8] = 1f;
  893. vertices[9] = 0f;
  894. // bottom right
  895. vertices[10] = left + width;
  896. vertices[11] = top + height;
  897. vertices[12] = 0f;
  898. vertices[13] = 1f;
  899. vertices[14] = 1f;
  900. // bottom left
  901. vertices[15] = left;
  902. vertices[16] = top + height;
  903. vertices[17] = 0f;
  904. vertices[18] = 0f;
  905. vertices[19] = 1f;
  906. }
  907. }
  908. static class Program {
  909. static void Main(string[] args) {
  910. List<MonitorInfo> monitors = Monitors.GetMonitors();
  911. MonitorInfo bestMonitor = monitors[0];
  912. int bestResolution = bestMonitor.HorizontalResolution * bestMonitor.VerticalResolution;
  913. for (int i = 1; i < monitors.Count; i++) {
  914. MonitorInfo monitor = monitors[i];
  915. int resolution = monitor.HorizontalResolution * monitor.VerticalResolution;
  916. if (resolution > bestResolution) {
  917. bestResolution = resolution;
  918. bestMonitor = monitor;
  919. }
  920. }
  921. Console.WriteLine(
  922. $"best monitor: {bestMonitor.HorizontalResolution}x{bestMonitor.VerticalResolution}");
  923. GameWindowSettings gwSettings = new();
  924. gwSettings.UpdateFrequency = 30.0;
  925. gwSettings.RenderFrequency = 30.0;
  926. NativeWindowSettings nwSettings = new();
  927. nwSettings.WindowState = WindowState.Normal;
  928. nwSettings.CurrentMonitor = bestMonitor.Handle;
  929. nwSettings.Location = new Vector2i(bestMonitor.WorkArea.Min.X + 1,
  930. bestMonitor.WorkArea.Min.Y + 31);
  931. nwSettings.Size = new Vector2i(bestMonitor.WorkArea.Size.X - 2,
  932. bestMonitor.WorkArea.Size.Y - 32);
  933. // nwSettings.Size = new Vector2i(1600, 900);
  934. nwSettings.MinimumSize = UiGeometry.MIN_WINDOW_SIZE;
  935. nwSettings.Title = "Totte";
  936. nwSettings.IsEventDriven = false;
  937. nwSettings.Icon = new WindowIcon(Util.RenderAppIcon());
  938. using (Game game = new(gwSettings, nwSettings)) {
  939. game.Run();
  940. }
  941. }
  942. }