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.

1080 lines
36 KiB

1 year ago
12 months ago
1 year ago
12 months ago
1 year ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
12 months ago
1 year ago
1 year ago
12 months ago
1 year ago
1 year ago
11 months 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
12 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
12 months ago
1 year ago
1 year ago
1 year ago
12 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. int numThumbnailsLoaded = 0;
  348. readonly object numThumbnailsLoadedLock = new();
  349. List<Photo> allPhotos = new();
  350. List<Photo> photos = new();
  351. HashSet<Photo> loadedImages = new();
  352. HashSet<Photo> loadingImages = new();
  353. readonly object loadedImagesLock = new();
  354. readonly ViewTool viewTool = new ViewTool();
  355. Toast toast = new();
  356. ITool activeTool;
  357. int photoIndex = 0;
  358. int ribbonIndex = 0;
  359. Vector2i mousePosition;
  360. float activeScale = 1f;
  361. Vector2i activeOffset;
  362. Transform transform = new(1f, Vector2i.Zero, Vector2i.Zero);
  363. Shader shader = new();
  364. Matrix4 projection;
  365. float zoomLevel = 0f;
  366. double timeSinceEvent = 0;
  367. protected override void OnUpdateFrame(FrameEventArgs e) {
  368. base.OnUpdateFrame(e);
  369. toast.Update(e.Time);
  370. KeyboardState input = KeyboardState;
  371. bool mouseInWindow = ClientRectangle.ContainsInclusive((Vector2i) MouseState.Position);
  372. if (input.IsAnyKeyDown ||
  373. MouseState.IsAnyButtonDown ||
  374. (mouseInWindow && MouseState.Delta != Vector2i.Zero)) {
  375. timeSinceEvent = 0;
  376. } else {
  377. timeSinceEvent += e.Time;
  378. }
  379. if (IsFocused && timeSinceEvent < 1) {
  380. RenderFrequency = 30;
  381. UpdateFrequency = 30;
  382. } else {
  383. RenderFrequency = 5;
  384. UpdateFrequency = 5;
  385. }
  386. Photo previousPhoto = photos[photoIndex];
  387. bool shiftIsDown = input.IsKeyDown(Keys.LeftShift) || input.IsKeyDown(Keys.RightShift);
  388. bool altIsDown = input.IsKeyDown(Keys.LeftAlt) || input.IsKeyDown(Keys.RightAlt);
  389. bool ctrlIsDown = input.IsKeyDown(Keys.LeftControl) || input.IsKeyDown(Keys.RightControl);
  390. // FIXME: add a confirm dialog before closing. (Also for the window-close button.)
  391. // FIXME: don't quit if there's pending file-write operations.
  392. // Close when Ctrl-Q is pressed.
  393. if (input.IsKeyPressed(Keys.Q) && ctrlIsDown) {
  394. Close();
  395. }
  396. mousePosition = (Vector2i) MouseState.Position;
  397. // Look for mouse clicks on thumbnails or stars.
  398. //
  399. // Note that we don't bounds-check photoIndex until after all the possible
  400. // inputs that might affect it. That simplifies this logic significantly.
  401. if (MouseState.IsButtonPressed(MouseButton.Button1)) {
  402. for (int i = 0; i < geometry.StarBoxes.Count; i++) {
  403. if (geometry.StarBoxes[i].ContainsInclusive(mousePosition)) {
  404. photos[photoIndex].Rating = i + 1;
  405. }
  406. }
  407. for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
  408. if (geometry.ThumbnailBoxes[i].ContainsInclusive(mousePosition)) {
  409. photoIndex = ribbonIndex + i;
  410. }
  411. }
  412. }
  413. if (MouseState.IsButtonPressed(MouseButton.Button4)) {
  414. photoIndex--;
  415. }
  416. if (MouseState.IsButtonPressed(MouseButton.Button5)) {
  417. photoIndex++;
  418. }
  419. if (MouseState.ScrollDelta.Y < 0) {
  420. photoIndex++;
  421. }
  422. if (MouseState.ScrollDelta.Y > 0) {
  423. photoIndex--;
  424. }
  425. if (input.IsKeyPressed(Keys.Down)) {
  426. photoIndex++;
  427. }
  428. if (input.IsKeyPressed(Keys.Up)) {
  429. photoIndex--;
  430. }
  431. if (input.IsKeyPressed(Keys.Home)) {
  432. photoIndex = 0;
  433. }
  434. if (input.IsKeyPressed(Keys.End)) {
  435. photoIndex = photos.Count - 1;
  436. }
  437. if (input.IsKeyPressed(Keys.PageDown)) {
  438. photoIndex += 5;
  439. }
  440. if (input.IsKeyPressed(Keys.PageUp)) {
  441. photoIndex -= 5;
  442. }
  443. if (input.IsKeyPressed(Keys.P) && ctrlIsDown) {
  444. ExportPhotos();
  445. }
  446. // Make sure the photoIndex is actually valid.
  447. if (photos.Count == 0) {
  448. photoIndex = 0;
  449. } else {
  450. photoIndex = Math.Clamp(photoIndex, 0, photos.Count - 1);
  451. }
  452. // Handle presses of the "rating" keys -- 0-5 and `.
  453. // A normal press just sets the rating of the current photo.
  454. // If the user is holding "shift", we instead filter to only show photos
  455. // of that rating or higher.
  456. int rating = -1;
  457. if (input.IsKeyPressed(Keys.D0) || input.IsKeyPressed(Keys.GraveAccent)) {
  458. rating = 0;
  459. }
  460. if (input.IsKeyPressed(Keys.D1)) {
  461. rating = 1;
  462. }
  463. if (input.IsKeyPressed(Keys.D2)) {
  464. rating = 2;
  465. }
  466. if (input.IsKeyPressed(Keys.D3)) {
  467. rating = 3;
  468. }
  469. if (input.IsKeyPressed(Keys.D4)) {
  470. rating = 4;
  471. }
  472. if (input.IsKeyPressed(Keys.D5)) {
  473. rating = 5;
  474. }
  475. if (rating >= 0) {
  476. if (shiftIsDown) {
  477. FilterByRating(rating);
  478. } else {
  479. if (photos.Count > 0) {
  480. photos[photoIndex].Rating = rating;
  481. }
  482. }
  483. }
  484. if (input.IsKeyPressed(Keys.Q)) {
  485. zoomLevel = 0f;
  486. }
  487. if (input.IsKeyPressed(Keys.W)) {
  488. zoomLevel = 1f;
  489. }
  490. if (input.IsKeyPressed(Keys.E)) {
  491. zoomLevel = 2f;
  492. }
  493. if (input.IsKeyPressed(Keys.R)) {
  494. zoomLevel = 4f;
  495. }
  496. if (input.IsKeyPressed(Keys.T)) {
  497. zoomLevel = 8f;
  498. }
  499. if (input.IsKeyPressed(Keys.Y)) {
  500. zoomLevel = 16f;
  501. }
  502. // Handle tool switching.
  503. if (photos[photoIndex] != previousPhoto) {
  504. activeTool = viewTool;
  505. }
  506. if (activeTool == viewTool) {
  507. if (input.IsKeyPressed(Keys.C)) {
  508. activeTool = new CropTool(photos[photoIndex]);
  509. }
  510. }
  511. // Delegate input to the active tool.
  512. ToolStatus status = activeTool.HandleInput(KeyboardState, MouseState, transform, this);
  513. // Change back to the default tool if the active tool is done.
  514. if (status != ToolStatus.Active) {
  515. activeTool = viewTool;
  516. }
  517. }
  518. void FilterByRating(int rating) {
  519. Console.WriteLine("filter to " + rating);
  520. Photo previouslyActive = photos.Count > 0 ? photos[photoIndex] : allPhotos[0];
  521. photos = allPhotos.Where(p => p.Rating >= rating).ToList();
  522. // Move photoIndex to wherever the previously active photo was, or if it
  523. // was filtered out, to whichever unfiltered photo comes before it. This
  524. // is O(n) in the length of allPhotos, but how bad can it be? :)
  525. photoIndex = -1;
  526. for (int i = 0; i < allPhotos.Count; i++) {
  527. Photo candidate = allPhotos[i];
  528. if (candidate.Rating >= rating) {
  529. photoIndex++;
  530. }
  531. if (candidate == previouslyActive) {
  532. break;
  533. }
  534. }
  535. photoIndex = Math.Max(0, photoIndex);
  536. }
  537. // FIXME: switch to immediate mode??
  538. // https://gamedev.stackexchange.com/questions/198805/opentk-immediate-mode-on-net-core-doesnt-work
  539. // https://www.youtube.com/watch?v=Q23Kf9QEaO4
  540. protected override void OnLoad() {
  541. base.OnLoad();
  542. GL.ClearColor(0f, 0f, 0f, 1f);
  543. GL.Enable(EnableCap.Blend);
  544. GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
  545. VertexArrayObject = GL.GenVertexArray();
  546. GL.BindVertexArray(VertexArrayObject);
  547. VertexBufferObject = GL.GenBuffer();
  548. ElementBufferObject = GL.GenBuffer();
  549. GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
  550. GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float),
  551. vertices, BufferUsageHint.DynamicDraw);
  552. GL.BindBuffer(BufferTarget.ElementArrayBuffer, ElementBufferObject);
  553. GL.BufferData(BufferTarget.ElementArrayBuffer, indices.Length * sizeof(uint),
  554. indices, BufferUsageHint.DynamicDraw);
  555. shader.Init();
  556. shader.Use();
  557. // Because there's 5 floats between the start of the first vertex and the start of the second,
  558. // the stride is 5 * sizeof(float).
  559. // This will now pass the new vertex array to the buffer.
  560. var vertexLocation = shader.GetAttribLocation("aPosition");
  561. GL.EnableVertexAttribArray(vertexLocation);
  562. GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float,
  563. false, 5 * sizeof(float), 0);
  564. // Next, we also setup texture coordinates. It works in much the same way.
  565. // We add an offset of 3, since the texture coordinates comes after the position data.
  566. // We also change the amount of data to 2 because there's only 2 floats for texture coordinates.
  567. var texCoordLocation = shader.GetAttribLocation("aTexCoord");
  568. GL.EnableVertexAttribArray(texCoordLocation);
  569. GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float,
  570. false, 5 * sizeof(float), 3 * sizeof(float));
  571. // Load photos from a directory.
  572. string[] files = Directory.GetFiles(@"c:\users\colin\desktop\photos-test\");
  573. // string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\14\");
  574. // string[] files = Directory.GetFiles(@"c:\users\colin\pictures\photos\2023\07\23\");
  575. // string[] files = Directory.GetFiles(@"G:\DCIM\100EOSR6\");
  576. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\totte-output\2023\08\29");
  577. // string[] files = Directory.GetFiles(@"c:\users\colin\desktop\import");
  578. // string[] files = Directory.GetFiles(@"C:\Users\colin\Pictures\photos\2018\06\23");
  579. // string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\Germany all\104D7000");
  580. // string[] files = Directory.GetFiles(@"C:\Users\colin\Desktop\many-birds\");
  581. for (int i = 0; i < files.Count(); i++) {
  582. string file = files[i];
  583. if (file.ToLower().EndsWith(".jpg")) {
  584. Photo photo = new Photo(file, TEXTURE_BLACK);
  585. allPhotos.Add(photo);
  586. }
  587. }
  588. allPhotos.Sort(ComparePhotosByDate);
  589. // Fix up photos with missing GPS. We start at the end and work our way
  590. // backwards, because if one photo is missing GPS, it's probably because
  591. // the camera was turned off for a while, and whichever photo *after* it
  592. // has GPS data is probably more accurate.
  593. GpsInfo? lastGps = null;
  594. for (int i = allPhotos.Count - 1; i >= 0; i--) {
  595. Photo p = allPhotos[i];
  596. if (p.Gps == null) {
  597. Console.WriteLine("fixing GPS for " + p.Filename);
  598. p.Gps = lastGps;
  599. } else {
  600. lastGps = p.Gps;
  601. }
  602. }
  603. photos = allPhotos;
  604. LoadThumbnailsAsync();
  605. }
  606. private static int ComparePhotosByDate(Photo x, Photo y) {
  607. int compare = x.DateTimeOriginal.CompareTo(y.DateTimeOriginal);
  608. if (compare != 0) {
  609. return compare;
  610. }
  611. // If the photos have the same seconds value, sort by filename
  612. // (since cameras usually increment the filename for successive shots.)
  613. return x.Filename.CompareTo(y.Filename);
  614. }
  615. protected override void OnUnload() {
  616. base.OnUnload();
  617. }
  618. private void UnloadImages() {
  619. // Unload images that haven't been touched in a while.
  620. lock (loadedImagesLock) {
  621. while (loadedImages.Count > 30) {
  622. long earliestTime = long.MaxValue;
  623. Photo? earliest = null;
  624. foreach (Photo photo in loadedImages) {
  625. if (photo.LastTouch < earliestTime) {
  626. earliest = photo;
  627. earliestTime = photo.LastTouch;
  628. }
  629. }
  630. if (earliest != null) {
  631. Console.WriteLine($"loadedImages.Count: {loadedImages.Count}, " +
  632. $"evicting {earliest.Filename} @ {earliestTime}");
  633. earliest.Unload();
  634. loadedImages.Remove(earliest);
  635. }
  636. }
  637. }
  638. }
  639. private async void LoadImagesAsync() {
  640. foreach (Photo p in loadingImages) {
  641. if (p.Loaded) {
  642. lock (loadedImagesLock) {
  643. loadedImages.Add(p);
  644. loadingImages.Remove(p);
  645. }
  646. }
  647. }
  648. // Start loading any images that are in our window but not yet loaded.
  649. int minLoadedImage = Math.Max(0, photoIndex - 10);
  650. int maxLoadedImage = Math.Min(photoIndex + 10, photos.Count - 1);
  651. List<Photo> toLoad = new();
  652. for (int i = minLoadedImage; i <= maxLoadedImage; i++) {
  653. lock (loadedImagesLock) {
  654. if (!loadedImages.Contains(photos[i]) && !loadingImages.Contains(photos[i])) {
  655. Console.WriteLine("loading " + i);
  656. loadingImages.Add(photos[i]);
  657. toLoad.Add(photos[i]);
  658. }
  659. }
  660. }
  661. foreach (Photo p in toLoad) {
  662. await Task.Run( () => { p.LoadAsync(p.Size); });
  663. }
  664. }
  665. private async void LoadThumbnailsAsync() {
  666. List<Task> tasks = new();
  667. foreach (Photo p in allPhotos) {
  668. tasks.Add(Task.Run( () => {
  669. p.LoadThumbnailAsync(geometry.ThumbnailSize);
  670. lock (numThumbnailsLoadedLock) {
  671. numThumbnailsLoaded++;
  672. toast.Set($"Loading thumbnails: {numThumbnailsLoaded}/{allPhotos.Count}");
  673. }
  674. }));
  675. }
  676. await Task.WhenAll(tasks).ContinueWith(t => { toast.Set("Loading thumbnails: done!"); });
  677. }
  678. // To find the JPEG compression level of a file from the command line:
  679. // $ identify -verbose image.jpg | grep Quality:
  680. // FIXME: don't ExportPhotos() if another export is already active.
  681. // FIXME: show a progress bar or something.
  682. private async void ExportPhotos() {
  683. JpegEncoder encoder = new JpegEncoder() { Quality = 100 };
  684. foreach (Photo p in photos) {
  685. await Task.Run( () => { p.SaveAsJpegAsync(outputRoot, encoder); });
  686. }
  687. }
  688. protected override void OnRenderFrame(FrameEventArgs e) {
  689. base.OnRenderFrame(e);
  690. fpsCounter.Update();
  691. UnloadImages();
  692. LoadImagesAsync();
  693. GL.Clear(ClearBufferMask.ColorBufferBit);
  694. GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
  695. GL.ActiveTexture(TextureUnit.Texture0);
  696. if (photos.Count > 0) {
  697. DrawPhotos();
  698. } else {
  699. DrawText("No photos found.", 10, 10);
  700. }
  701. activeTool.Draw(geometry, this);
  702. SwapBuffers();
  703. }
  704. void DrawPhotos() {
  705. Photo activePhoto = photos[photoIndex];
  706. Texture active = activePhoto.Texture();
  707. // FIXME: make a function for scaling & centering one box on another.
  708. // FIXME: cropping is fucky because activeScale is using the texture size, not the photo size.
  709. float scaleX = 1f * geometry.PhotoBox.Size.X / active.Size.X;
  710. float scaleY = 1f * geometry.PhotoBox.Size.Y / active.Size.Y;
  711. float scale = Math.Min(scaleX, scaleY);
  712. if (zoomLevel > 0f) {
  713. scale = zoomLevel;
  714. }
  715. activeScale = scale;
  716. Vector2i renderSize = (Vector2i) (((Vector2) active.Size) * scale);
  717. Vector2i center = (Vector2i) geometry.PhotoBox.Center;
  718. Box2i photoBox = Util.MakeBox(center.X - renderSize.X / 2, center.Y - renderSize.Y / 2,
  719. renderSize.X, renderSize.Y);
  720. activeOffset = new(photoBox.Min.X, photoBox.Min.Y);
  721. transform = new Transform(activeScale, activeOffset, activePhoto.Size);
  722. DrawTexture(active, photoBox);
  723. for (int i = 0; i < 5; i++) {
  724. Texture star = (activePhoto.Rating > i) ? STAR_FILLED : STAR_EMPTY;
  725. DrawTexture(star, geometry.StarBoxes[i].Min.X, geometry.StarBoxes[i].Min.Y);
  726. }
  727. bool cropActive = activeTool is CropTool;
  728. DrawCropRectangle(cropActive);
  729. // Draw thumbnail boxes.
  730. ribbonIndex = Math.Clamp(photoIndex - (geometry.ThumbnailBoxes.Count - 1) / 2,
  731. 0, Math.Max(0, photos.Count - geometry.ThumbnailBoxes.Count));
  732. DrawFilledBox(geometry.ThumbnailBox, Color4.Black);
  733. for (int i = 0; i < geometry.ThumbnailBoxes.Count; i++) {
  734. if (ribbonIndex + i >= photos.Count) {
  735. break;
  736. }
  737. Photo photo = photos[ribbonIndex + i];
  738. Box2i box = geometry.ThumbnailBoxes[i];
  739. DrawTexture(photo.ThumbnailTexture(), box);
  740. for (int j = 0; j < photo.Rating; j++) {
  741. DrawTexture(STAR_SMALL, box.Min.X + 8 + ((STAR_SMALL.Size.X + 2) * j), box.Min.Y + 8);
  742. }
  743. if (ribbonIndex + i == photoIndex) {
  744. DrawBox(box, 5, Color4.Black);
  745. DrawBox(box, 3, Color4.White);
  746. }
  747. }
  748. // Draw status box.
  749. int statusPadding = 2;
  750. DrawFilledBox(geometry.StatusBox, Color4.Black);
  751. // First line.
  752. int y = geometry.StatusBox.Min.Y + statusPadding;
  753. DrawText(String.Format("{0,4}/{1,-4}", photoIndex + 1, photos.Count),
  754. geometry.StatusBox.Min.X, y);
  755. DrawText(activePhoto.Description(), geometry.StatusBox.Min.X + 88, y);
  756. // Second line.
  757. y += 20;
  758. DrawText(activeTool.Status() + toast.Get(), geometry.StatusBox.Min.X, y);
  759. DrawText(String.Format("FPS: {0,2}", fpsCounter.Fps), geometry.StatusBox.Max.X - 66, y);
  760. if (activePhoto.Loaded) {
  761. DrawText($"{(scale * 100):F1}%", geometry.StatusBox.Max.X - 136, y);
  762. }
  763. }
  764. void DrawCropRectangle(bool active) {
  765. Photo activePhoto = photos[photoIndex];
  766. if (activePhoto.CropRectangle == Rectangle.Empty) {
  767. return;
  768. }
  769. Vector2i leftTop = transform.ImageToScreen(activePhoto.CropRectangle.Left,
  770. activePhoto.CropRectangle.Top);
  771. Vector2i rightBottom = transform.ImageToScreen(activePhoto.CropRectangle.Right,
  772. activePhoto.CropRectangle.Bottom);
  773. var (left, top) = leftTop;
  774. var (right, bottom) = rightBottom;
  775. Color4 shadeColor = new Color4(0, 0, 0, 0.75f);
  776. DrawFilledBox(new Box2i(0, 0, left, geometry.PhotoBox.Max.Y), shadeColor);
  777. DrawFilledBox(new Box2i(left, 0, geometry.PhotoBox.Max.X, top), shadeColor);
  778. DrawFilledBox(new Box2i(left, bottom, geometry.PhotoBox.Max.X, geometry.PhotoBox.Max.Y),
  779. shadeColor);
  780. DrawFilledBox(new Box2i(right, top, geometry.PhotoBox.Max.X, bottom), shadeColor);
  781. DrawBox(new Box2i(left - 1, top - 1, right + 1, bottom + 1), 1, Color4.White);
  782. if (active) {
  783. // handles
  784. int handleThickness = 3;
  785. int handleLength = 16;
  786. // top-left
  787. DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
  788. left + handleLength, top), Color4.White);
  789. DrawFilledBox(new Box2i(left - handleThickness, top - handleThickness,
  790. left, top + handleLength), Color4.White);
  791. // top-right
  792. DrawFilledBox(new Box2i(right - handleLength, top - handleThickness,
  793. right + handleThickness, top), Color4.White);
  794. DrawFilledBox(new Box2i(right, top - handleThickness,
  795. right + handleThickness, top + handleLength), Color4.White);
  796. // bottom-left
  797. DrawFilledBox(new Box2i(left - handleThickness, bottom,
  798. left + handleLength, bottom + handleThickness), Color4.White);
  799. DrawFilledBox(new Box2i(left - handleThickness, bottom - handleLength,
  800. left, bottom + handleThickness), Color4.White);
  801. // bottom-right
  802. DrawFilledBox(new Box2i(right - handleLength, bottom,
  803. right + handleThickness, bottom + handleThickness), Color4.White);
  804. DrawFilledBox(new Box2i(right + handleThickness, bottom - handleLength,
  805. right, bottom + handleThickness), Color4.White);
  806. // thirds
  807. DrawHorizontalLine(left, Util.Lerp(top, bottom, 1.0 / 3), right, Color4.White);
  808. DrawHorizontalLine(left, Util.Lerp(top, bottom, 2.0 / 3), right, Color4.White);
  809. DrawVerticalLine(Util.Lerp(left, right, 1.0 / 3), top, bottom, Color4.White);
  810. DrawVerticalLine(Util.Lerp(left, right, 2.0 / 3), top, bottom, Color4.White);
  811. }
  812. }
  813. public void DrawTexture(Texture texture, int x, int y) {
  814. DrawTexture(texture, Util.MakeBox(x, y, texture.Size.X, texture.Size.Y));
  815. }
  816. public void DrawTexture(Texture texture, Box2i box) {
  817. DrawTexture(texture, box, Color4.White);
  818. }
  819. public void DrawTexture(Texture texture, Box2i box, Color4 color) {
  820. GL.Uniform4(shader.GetUniformLocation("color"), color);
  821. SetVertices(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y);
  822. GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices,
  823. BufferUsageHint.DynamicDraw);
  824. GL.BindTexture(TextureTarget.Texture2D, texture.Handle);
  825. GL.DrawElements(PrimitiveType.Triangles, indices.Length, DrawElementsType.UnsignedInt, 0);
  826. }
  827. public void DrawHorizontalLine(int left, int top, int right, Color4 color) {
  828. DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, right - left, 1), color);
  829. }
  830. public void DrawVerticalLine(int left, int top, int bottom, Color4 color) {
  831. DrawTexture(TEXTURE_WHITE, Util.MakeBox(left, top, 1, bottom - top), color);
  832. }
  833. public void DrawBox(Box2i box, int thickness, Color4 color) {
  834. DrawTexture(TEXTURE_WHITE,
  835. Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, thickness), color);
  836. DrawTexture(TEXTURE_WHITE,
  837. Util.MakeBox(box.Min.X, box.Min.Y, thickness, box.Size.Y), color);
  838. DrawTexture(TEXTURE_WHITE,
  839. Util.MakeBox(box.Min.X, box.Max.Y - thickness, box.Size.X, thickness), color);
  840. DrawTexture(TEXTURE_WHITE,
  841. Util.MakeBox(box.Max.X - thickness, box.Min.Y, thickness, box.Size.Y), color);
  842. }
  843. public void DrawFilledBox(Box2i box, Color4 color) {
  844. DrawTexture(TEXTURE_WHITE, Util.MakeBox(box.Min.X, box.Min.Y, box.Size.X, box.Size.Y), color);
  845. }
  846. public void DrawText(string text, int x, int y) {
  847. Texture label = Util.RenderText(text);
  848. DrawTexture(label, x, y);
  849. label.Dispose();
  850. }
  851. protected override void OnResize(ResizeEventArgs e) {
  852. base.OnResize(e);
  853. Console.WriteLine($"OnResize: {e.Width}x{e.Height}");
  854. geometry = new UiGeometry(e.Size, STAR_FILLED.Size.X);
  855. projection = Matrix4.CreateOrthographicOffCenter(0f, e.Width, e.Height, 0f, -1f, 1f);
  856. GL.UniformMatrix4(shader.GetUniformLocation("projection"), true, ref projection);
  857. GL.Viewport(0, 0, e.Width, e.Height);
  858. }
  859. private void SetVertices(float left, float top, float width, float height) {
  860. // top left
  861. vertices[0] = left;
  862. vertices[1] = top;
  863. vertices[2] = 0f;
  864. vertices[3] = 0f;
  865. vertices[4] = 0f;
  866. // top right
  867. vertices[5] = left + width;
  868. vertices[6] = top;
  869. vertices[7] = 0f;
  870. vertices[8] = 1f;
  871. vertices[9] = 0f;
  872. // bottom right
  873. vertices[10] = left + width;
  874. vertices[11] = top + height;
  875. vertices[12] = 0f;
  876. vertices[13] = 1f;
  877. vertices[14] = 1f;
  878. // bottom left
  879. vertices[15] = left;
  880. vertices[16] = top + height;
  881. vertices[17] = 0f;
  882. vertices[18] = 0f;
  883. vertices[19] = 1f;
  884. }
  885. }
  886. static class Program {
  887. static void Main(string[] args) {
  888. List<MonitorInfo> monitors = Monitors.GetMonitors();
  889. MonitorInfo bestMonitor = monitors[0];
  890. int bestResolution = bestMonitor.HorizontalResolution * bestMonitor.VerticalResolution;
  891. for (int i = 1; i < monitors.Count; i++) {
  892. MonitorInfo monitor = monitors[i];
  893. int resolution = monitor.HorizontalResolution * monitor.VerticalResolution;
  894. if (resolution > bestResolution) {
  895. bestResolution = resolution;
  896. bestMonitor = monitor;
  897. }
  898. }
  899. Console.WriteLine(
  900. $"best monitor: {bestMonitor.HorizontalResolution}x{bestMonitor.VerticalResolution}");
  901. GameWindowSettings gwSettings = new();
  902. gwSettings.UpdateFrequency = 30.0;
  903. gwSettings.RenderFrequency = 30.0;
  904. NativeWindowSettings nwSettings = new();
  905. nwSettings.WindowState = WindowState.Normal;
  906. nwSettings.CurrentMonitor = bestMonitor.Handle;
  907. nwSettings.Location = new Vector2i(bestMonitor.WorkArea.Min.X + 1,
  908. bestMonitor.WorkArea.Min.Y + 31);
  909. nwSettings.Size = new Vector2i(bestMonitor.WorkArea.Size.X - 2,
  910. bestMonitor.WorkArea.Size.Y - 32);
  911. // nwSettings.Size = new Vector2i(1600, 900);
  912. nwSettings.MinimumSize = UiGeometry.MIN_WINDOW_SIZE;
  913. nwSettings.Title = "Totte";
  914. nwSettings.IsEventDriven = false;
  915. nwSettings.Icon = new WindowIcon(Util.RenderAppIcon());
  916. using (Game game = new(gwSettings, nwSettings)) {
  917. game.Run();
  918. }
  919. }
  920. }