Compare commits

..

192 Commits

Author SHA1 Message Date
ce4a726a03 change settings to ones sensible for my new desktop 2023-12-28 22:19:46 -05:00
4ce32172f9 update to net8.0 2023-12-28 22:07:32 -05:00
ed4044a115 put raws into root folder, jpgs into jpg/. thumbnail size tweaked to be the same as what uigeometry says 2023-09-19 22:18:12 -04:00
775252b567 thumbnail size to 150x150 2023-09-19 14:14:07 -04:00
7bd3719c72 performance enhancements 2023-09-19 14:11:03 -04:00
87e029e7f8 rm some fixmes 2023-09-19 13:55:31 -04:00
063cb2f449 pass along some Toast to newly-named saveAsync() 2023-09-17 14:39:00 -04:00
5c8aa2520a draw guides for straightening 2023-09-15 15:42:58 -04:00
8b57464056 straighten-and-crop kinda works 2023-09-15 15:04:45 -04:00
b144da2399 StraightenTool: hold shift/ctrl for less/more rotation per keypress 2023-09-15 13:54:03 -04:00
a2a5448afe straightentool now works in 100ths of degrees 2023-09-15 13:17:10 -04:00
5f636dd910 add fixme 2023-09-15 13:11:44 -04:00
9db0ddd15c actually rotate the image in output 2023-09-15 13:10:41 -04:00
0417c354d5 rotate by 0.1 degrees per keypress 2023-09-15 11:53:41 -04:00
96526a5b76 actually rotate displayed photo correctly 2023-09-15 11:46:22 -04:00
4f3e9f50ed rotating a texture works, but only about the origin 2023-09-15 11:36:11 -04:00
45027ec8b9 put some spacing betwene tool status and toasts 2023-09-15 10:31:34 -04:00
f306fc3bc9 skeleton for StraightenTool 2023-09-15 10:28:24 -04:00
389ab12fb2 fix statusbox padding problem in UiGeometry 2023-09-15 01:48:12 -04:00
4cd0d4efc6 allow resizing the crop box by dragging handles 2023-09-15 01:42:01 -04:00
d72b804b92 move choice of encoders into Photo itself 2023-09-15 00:41:33 -04:00
7b7eb22c91 rm fixme 2023-09-14 23:41:00 -04:00
adc6bf7972 save raw/jpg/edits into separate directories 2023-09-14 23:40:09 -04:00
ae0fa8f934 when crop committed, set rating to (at least) 1 2023-09-01 10:27:39 -04:00
aa5b3e7083 multiple columns of thumbnails 2023-09-01 01:06:04 -04:00
31f2668aa3 automatically zoom in on crops 2023-09-01 00:00:55 -04:00
d72a90a9f0 rm fixme 2023-08-31 23:43:23 -04:00
4a84774855 don't grad the photo around unless the drag starts in the photobox 2023-08-31 23:40:35 -04:00
e612429298 add ViewOffset to Photo for display 2023-08-31 23:07:15 -04:00
31e419b81b page up/down moves position by 10 2023-08-31 22:15:05 -04:00
f612d0dc42 shorten a bunch of exif-parsing calls 2023-08-31 01:05:34 -04:00
75ad5bd478 add ExposureBiasValue to the statusbar 2023-08-30 23:35:23 -04:00
4f86338a3e parse SubsecTimeOriginal 2023-08-30 23:18:25 -04:00
5d70b4c54b ExportPhotos(): add progress on statusbar, skip if already exporting 2023-08-30 17:57:01 -04:00
c1ed48b917 don't hard-code aspect ratio 2023-08-30 15:39:04 -04:00
3d4ad61f5e go through gps data backwards 2023-08-30 13:59:35 -04:00
c0ac67103f fix up GPS more properly 2023-08-30 12:01:30 -04:00
0ca67f2cef fix up GPS, sorta 2023-08-30 01:06:00 -04:00
32403ddeb5 handle different directions of crop 2023-08-26 15:57:47 -04:00
7c7976a82d change mouse cursor when inside crop box 2023-08-26 15:37:57 -04:00
bcb2e7be7d only consider mouse-move events as "active" (for framerate purposes) if the pointer is in the window area 2023-08-26 13:14:24 -04:00
6478f1ee95 adapt frame rate based on whether events are happening 2023-08-26 12:55:06 -04:00
393369773b add toasts! and toast about thumbnail loading 2023-08-26 12:40:36 -04:00
9620a3af1d add photo dimensions to statusbar 2023-08-26 11:47:13 -04:00
e9a13dba49 print out thumbnail loading status to console 2023-08-26 00:12:31 -04:00
870daa3851 update some comments 2023-08-25 01:22:21 -04:00
95882f3135 skip metadata for texture loading 2023-08-25 01:04:47 -04:00
67736630e0 load smaller main images by default 2023-08-24 23:38:25 -04:00
50b0e8d8e0 load thumbnails for everything, async'ly on load 2023-08-24 22:36:52 -04:00
cbea050334 temporary test: loading small thumbs only 2023-08-24 21:25:06 -04:00
44752c5567 add fixme re photo export GPS 2023-08-23 02:30:01 -04:00
c053672108 draw crop handles (they don't do anything yet) 2023-08-22 21:21:24 -04:00
c77183915b wrap long columns to 100 characters 2023-08-05 23:21:18 -04:00
3d507f3794 move Texture into its own file 2023-08-05 23:12:18 -04:00
d63bdb3265 alt-P -> ctrl-P 2023-08-05 22:56:40 -04:00
e736d2db5f remove most of CameraInfo, just use an AspectRatio instead 2023-08-05 15:10:10 -04:00
82e9f59f5e pull out a separate Transform class 2023-08-05 14:34:06 -04:00
1c586a6197 allow user to drag around the crop box 2023-08-04 00:43:47 -04:00
c953fa2b47 refactor CropTool.HandleInput a bit 2023-08-04 00:17:11 -04:00
1793fee616 restore crop position for a photo who gets edited with CropTool again 2023-08-03 23:46:46 -04:00
304097177c add namespace to Shader 2023-08-03 23:41:32 -04:00
48072d7b4a pull out Photo into its own file 2023-08-03 23:41:14 -04:00
2a28f94dc1 remove SetActivePhoto() functions 2023-08-03 23:33:48 -04:00
086088fb4c move the white border of crop rectangle out by 1 2023-08-03 23:29:27 -04:00
ff938693ff draw crop rectangle when viewing a cropped photo 2023-08-03 23:21:10 -04:00
08862f52af draw newly-refactored cropbox on screen etc 2023-08-03 21:33:28 -04:00
8d4bf9c69f bug fixes and performance improvements 2023-08-03 20:03:46 -04:00
6df7f1e53b rearrange statusbar a bit 2023-08-03 19:38:53 -04:00
4514509c62 pull out Shader into its own file 2023-08-03 19:32:53 -04:00
6076abe9d1 return ToolState and start switching tools 2023-08-03 18:38:21 -04:00
7db0ec7e62 refactor a bunch of stuff into a CropTool 2023-08-03 18:14:19 -04:00
6d07a533d7 rearrange & shorten some statusbar items 2023-08-02 22:29:04 -04:00
473fac7a6f cropping works! janky af for now, but it works 2023-08-02 01:05:10 -04:00
e5c1b01806 more crop progress 2023-08-01 23:14:00 -04:00
190dda46d4 more cropbox work 2023-08-01 22:24:25 -04:00
1ad8b06be8 add ability to draw a simple crop box 2023-08-01 14:12:52 -04:00
9cdef230f4 add Rating to EXIF as well just in case that matters somehow 2023-08-01 11:30:06 -04:00
58a19d061d save rating in XMP data. also change default window size 2023-07-31 20:50:53 -04:00
9e03d8f2c5 change Export keybinding to Alt-P 2023-07-31 17:01:08 -04:00
c68f42915c output to the "prod" photos location. also make sure there's at least one thumbnail in the ribbon 2023-07-31 16:44:01 -04:00
ac93b55609 change Export to shift-P 2023-07-31 00:58:21 -04:00
c74d5c5385 add some things to exif output 2023-07-28 17:54:00 -04:00
831637338c ExportPhotos: load images async'ly 2023-07-28 16:32:54 -04:00
cf354937e7 async'ify ExportPhotos 2023-07-28 16:28:18 -04:00
a326c8432e bump JPEG quality from 98->100 and add a comment 2023-07-28 16:00:08 -04:00
073c9745e9 basic image export! 2023-07-28 15:52:36 -04:00
3e4bd73091 add revision # to packaged zip file 2023-07-28 14:49:48 -04:00
fe02e13a11 keep around more loaded images 2023-07-28 14:36:02 -04:00
33f6ee739e introduce loadingImages & add a lock around image load/unload state 2023-07-28 13:07:47 -04:00
ad598e68a5 rm spurious "async" keyword 2023-07-28 12:27:53 -04:00
a36bad5c1f add fixme re exif times 2023-07-28 12:21:38 -04:00
3a0060fc9d fix memory leak (and crash under debugging) by not trying to unload textures on a non-GL thread 2023-07-28 12:19:15 -04:00
75b80507bd handle "filter result size = 0" without crashing 2023-07-28 11:52:07 -04:00
1bedac471d show "photo # / total photos" in status bar 2023-07-27 01:18:17 -04:00
0b6de0840f Move photoIndex to wherever the previously active photo was 2023-07-26 21:56:27 -04:00
ca5b2d94f5 unload Photos by least-recently-used 2023-07-26 21:29:26 -04:00
dc909a80f4 cache images by string (filename) rather than index; disable unloading for now 2023-07-26 18:16:51 -04:00
3c1e2c8dad start work on filtering by rating 2023-07-26 17:29:59 -04:00
067b54a77d remove keyboard autorepeat and change IsKeyDown -> IsKeyPressed 2023-07-26 16:57:32 -04:00
42ea309794 add keybindings for rating 2023-07-26 15:41:53 -04:00
247d6ac6f2 click on stars to set rating 2023-07-26 15:20:20 -04:00
8ca4ff7cad put stars at the center bottom and make f/ stringification simpler 2023-07-26 15:09:19 -04:00
e98b19d87b make DrawTexture(texture, x, y) function 2023-07-26 13:49:48 -04:00
7ce97438ae add stars to the thumbnail view 2023-07-26 13:10:19 -04:00
77b0f2b191 display rating info in upper left 2023-07-26 12:23:58 -04:00
7b8c64d8be little changes to thumbnails / image load/unload constants 2023-07-26 11:38:04 -04:00
f83d7f13aa FpsCounter frameTimes size: 60 -> 30 2023-07-26 11:25:14 -04:00
a92d089c5b add poadding to top of statusbar 2023-07-26 11:24:56 -04:00
3012f67c9f use a more universally-available japanese font 2023-07-26 11:14:23 -04:00
a67912e1ce make icon size a variable 2023-07-26 11:11:06 -04:00
c7ff5ee15d render app icon 2023-07-26 10:00:46 -04:00
07d25742fb try IsEventDriven = true 2023-07-25 22:49:09 -04:00
57e6968f3c add fps counter 2023-07-25 22:19:18 -04:00
32cb03ab15 Photo.Unload() is now async 2023-07-25 22:00:43 -04:00
b0fd20cd89 add PageUp/PageDown/Home/End keybindings for going through photos 2023-07-25 21:44:22 -04:00
27a4a64ccb load and unload images dynamically 2023-07-25 21:24:20 -04:00
fb26de5fa1 show date in status bar 2023-07-25 20:35:12 -04:00
3b85c5d21a sort photos by creation time 2023-07-25 20:25:07 -04:00
af4827a127 add some links to comments so i can close the chrome tabs :) 2023-07-25 18:49:52 -04:00
50445dbe59 allow mouse forward/back buttons to advance photoIndex. simplify photoIndex logic a bit 2023-07-25 18:38:21 -04:00
3cf125fba7 make the ribbon actually scroll down off the page etc 2023-07-25 18:26:46 -04:00
f9aeb81313 draw black background for thumbnail boxes 2023-07-25 17:18:11 -04:00
4f1adef895 minor cleanups 2023-07-25 17:10:51 -04:00
060565b44a load all metadata at beginning, load textures afterwards 2023-07-25 16:58:41 -04:00
9796827f96 pull ParseExif() into a standalone function 2023-07-25 16:43:11 -04:00
e6d3f197ea ah right, Orientation 4 is just a vertical flip 2023-07-25 15:02:40 -04:00
1336498682 add skeptical comment 2023-07-25 15:00:30 -04:00
4f667f2400 handle all 8 Exif orientations 2023-07-25 14:48:02 -04:00
c461d55101 start of Exif orientation handling 2023-07-25 14:28:57 -04:00
98cae0e9c1 add comment about exif width/height tags 2023-07-25 11:05:00 -04:00
cb01d1dbea ParseRating -> TryParseRating 2023-07-25 09:54:41 -04:00
a33304ae7d draw status box background black 2023-07-25 09:45:40 -04:00
3a9192b478 parse star ratings from canon image metadata 2023-07-25 09:41:13 -04:00
7f375a6446 add zip file to gitignore 2023-07-24 18:56:53 -04:00
d52e8fce79 clean up null checks on CameraModel/LensModel 2023-07-24 18:55:59 -04:00
cdc2cb336d add script to package a debug zip 2023-07-24 18:55:30 -04:00
25ca0d99ff handle ExposureTime edge cases (for long exposures)
use a black texture while loading instead of white

print some more error messages in EXIF edge cases
2023-07-24 17:13:19 -04:00
6c7dbe5516 add focal length to EXIF display & tweak other EXIF display 2023-07-24 16:39:01 -04:00
4d63f64be6 display some exif data on-screen 2023-07-24 16:07:17 -04:00
56311bc56c add StatusBox geometry and draw it 2023-07-24 13:14:07 -04:00
a0d6267905 make a DrawText function; draw the active photo's filename 2023-07-24 12:53:21 -04:00
028419eb20 String.Format -> $"" 2023-07-24 12:45:41 -04:00
4c72f797ab capitalize function names like a C# programmer 2023-07-24 12:43:38 -04:00
fe9da67b04 draw text indicating zoom level 2023-07-24 12:36:44 -04:00
f853ef7cda add the ability to render text labels and stars 2023-07-24 12:13:13 -04:00
ec6be80143 do memegen-style text better. also fix indentation 2023-07-23 19:29:47 -04:00
9de8c6de51 add sample code for drawing text and shapes 2023-07-23 18:42:08 -04:00
4a857b461c let ` set zoom to 0 2023-07-23 17:34:42 -04:00
b02647d469 add some basic zoom keybinds 2023-07-18 01:42:59 -04:00
4b66284c65 don't use mipmaps? (makes texture loading much slower) 2023-07-17 02:51:15 -04:00
0bc0d97122 load Photos more async'ly 2023-07-17 02:44:34 -04:00
faee485b5c start to load images async 2023-07-16 23:45:50 -04:00
693269b8f5 make photo loading a separate thing from construction 2023-07-16 20:06:37 -04:00
e58f717ffe rename textureIndex -> photoIndex 2023-07-16 19:25:28 -04:00
75e186c392 add new Photo class 2023-07-16 19:23:03 -04:00
8600a7e490 allow keyboard repeat for moving between thumbnails 2023-07-16 19:13:08 -04:00
ee3af4fc57 change hard-coded photo dir 2023-07-09 17:06:33 -04:00
b3e3d3d11a use fancy new type-inferring new() 2023-07-08 00:33:15 -04:00
22aa1e0d88 clean up photo / letterbox centering code 2023-07-08 00:30:05 -04:00
87c8b6cfd0 rm spurious whitespace 2023-07-08 00:20:23 -04:00
95397087d2 let mouse clicks change active photo 2023-07-08 00:18:31 -04:00
5194fec400 clean up TEXTURE_WHITE initialization & thumbnail geometry 2023-07-08 00:04:45 -04:00
ec2989a9b0 use a Vector2i for Texture size 2023-07-07 23:41:32 -04:00
30f21943a8 rm spurious comment 2023-07-07 23:36:49 -04:00
30ac76f4c0 construct shader & WHITE_TEXTURE at Game constructor time 2023-07-07 23:29:51 -04:00
72e65b912c fix letterboxing calculation & use new UiGeometry 2023-07-07 16:00:43 -04:00
b41321b0c7 move more calculations into UiGeometry 2023-07-07 14:52:36 -04:00
6812699401 pull some calculations into a new UiGeometry class 2023-07-07 14:24:43 -04:00
6670b358c0 add black border on the drawn box 2023-07-07 02:33:01 -04:00
eff73b2ced make glClearColor actually black 2023-07-07 01:44:51 -04:00
5c2b831ced send Color4 to GL.Uniform4 2023-07-07 01:07:58 -04:00
5b135bc889 use Box2i instead of Rectangle 2023-07-06 23:48:12 -04:00
c7bb0deb0c use Color4 constants 2023-07-06 23:36:55 -04:00
cadc8e4779 Draw{Texture,Box}: allow passing in a different draw color 2023-07-06 23:14:55 -04:00
dd7c3f6615 rm unused imports 2023-07-06 22:33:25 -04:00
ca4ea40916 remove non-Rect versions of Draw{Texture,Box} 2023-07-06 22:13:26 -04:00
cb3994a378 rm borderWidth constant, box thickness 2->3 2023-07-06 21:59:50 -04:00
4ed9ed0796 make Draw{Box,Texture} take a Rectangle 2023-07-06 21:55:51 -04:00
536abbf2b4 add DrawTexture() and DrawBox() functions 2023-07-06 21:26:50 -04:00
4e0a15e3a0 move TEXTURE_WHITE loading; rm shader.Use() from OnRenderFrame() 2023-07-06 19:18:59 -04:00
a94a3938dc add blank white texture 2023-07-06 18:39:17 -04:00
ab9d9a0709 remove frameCount for now 2023-07-06 18:34:34 -04:00
855c97241b add CameraInfo class; load textures from Image rather than a pathname 2023-07-06 18:31:50 -04:00
904726aba6 SetVertices() now takes in a width and height instead of right and bottom 2023-06-29 22:57:38 -04:00
0625040304 letterbox the image so that the aspect ratio is right 2023-06-29 22:52:52 -04:00
a3360b1420 add min window size & use some constants for computing draw positions 2023-06-29 22:31:14 -04:00
f822a07602 add Width and Height to Texture 2023-06-29 22:21:41 -04:00
712472bdfb dos2unix 2023-06-29 22:10:24 -04:00
225a1a623c render little thumbnails for each loaded image 2023-06-29 22:09:08 -04:00
6fc66aba8f use arrows to flip through loaded images 2023-06-29 19:12:40 -04:00
20f8b1e285 load multiple textures, one for every JPG in the input directory 2023-06-29 16:01:49 -04:00
d3e7718b5f make a Texture object 2023-06-29 15:51:42 -04:00
7 changed files with 1914 additions and 279 deletions

3
.gitignore vendored
View File

@ -4,3 +4,6 @@
# Build results
[Bb]in/
[Oo]bj/
# Packaged Zip file.
totte.zip

440
Photo.cs Normal file
View File

@ -0,0 +1,440 @@
using OpenTK.Mathematics;
using Image = SixLabors.ImageSharp.Image;
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
using static System.IO.Path;
using System.Text;
using System.Xml.Linq;
namespace SemiColinGames;
// https://exiftool.org/TagNames/GPS.html
public struct GpsInfo {
public byte[] VersionId;
public string Status;
public string Datestamp;
public Rational[] Timestamp;
public Rational[] Latitude;
public string LatitudeRef;
public Rational[] Longitude;
public string LongitudeRef;
public Rational Altitude;
public byte AltitudeRef;
public static GpsInfo? ParseExif(ExifProfile exif) {
GpsInfo gps;
if (!Parse(exif, ExifTag.GPSVersionID, out gps.VersionId)) {
return null;
}
if (!Parse(exif, ExifTag.GPSStatus, out gps.Status)) {
return null;
}
if (!Parse(exif, ExifTag.GPSDateStamp, out gps.Datestamp)) {
return null;
}
if (!Parse(exif, ExifTag.GPSTimestamp, out gps.Timestamp)) {
return null;
}
if (!Parse(exif, ExifTag.GPSLatitude, out gps.Latitude)) {
return null;
}
if (!Parse(exif, ExifTag.GPSLatitudeRef, out gps.LatitudeRef)) {
return null;
}
if (!Parse(exif, ExifTag.GPSLongitude, out gps.Longitude)) {
return null;
}
if (!Parse(exif, ExifTag.GPSLongitudeRef, out gps.LongitudeRef)) {
return null;
}
if (!Parse(exif, ExifTag.GPSAltitude, out gps.Altitude)) {
return null;
}
if (!Parse(exif, ExifTag.GPSAltitudeRef, out gps.AltitudeRef)) {
return null;
}
return gps;
}
// FIXME: use this Parse() function in Photo.ParseExif() as well?
private static bool Parse<T>(ExifProfile exif, ExifTag<T> tag, out T result) {
IExifValue<T>? data;
if (exif.TryGetValue(tag, out data)) {
if (data != null && data.Value != null) {
result = data.Value;
return true;
}
}
#pragma warning disable CS8601
result = default(T);
#pragma warning restore CS8601
return false;
}
}
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 ShortLensModel = "";
public string FocalLength = "<unk>";
public string FNumber = "<unk>";
public string ExposureTime = "<unk>";
public string IsoSpeed = "<unk>";
public string ExposureBiasValue = "<unk>";
public int Rating = 0;
public ushort Orientation = 1;
public GpsInfo? Gps = null;
public Rectangle CropRectangle = Rectangle.Empty;
public Vector2i ViewOffset = Vector2i.Zero;
public int RotationDegreeHundredths = 0;
private static long touchCounter = 0;
private Texture texture;
private Texture placeholder;
private Texture thumbnailTexture;
private Image<Rgba32>? image = null;
private Image<Rgba32>? thumbnail = null;
public Photo(string filename, Texture placeholder) {
Filename = filename;
this.placeholder = placeholder;
texture = placeholder;
thumbnailTexture = 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(Vector2i size) {
// 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++;
// FIXME: if we zoom in to more than the display size, actually load the whole image?
DecoderOptions options = new DecoderOptions {
TargetSize = new Size(size.X, size.Y),
SkipMetadata = true
};
Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(options, Filename);
Util.RotateImageFromExif(tmp, Orientation);
image = tmp;
}
public async void LoadThumbnailAsync(Vector2i size) {
DecoderOptions options = new DecoderOptions {
TargetSize = new Size(size.X, size.Y),
SkipMetadata = true
};
Image<Rgba32> tmp = await Image.LoadAsync<Rgba32>(options, Filename);
Util.RotateImageFromExif(tmp, Orientation);
thumbnail = tmp;
}
public void Unload() {
Loaded = false;
if (texture != placeholder) {
texture.Dispose();
texture = placeholder;
}
}
public async void SaveAsync(string outputRoot, Toast toast) {
// FIXME: if nothing was changed about this image, just copy the file bytes directly, possibly with metadata changed?
string directory = Path.Combine(
outputRoot,
String.Format("{0:D4}", DateTimeOriginal.Year),
String.Format("{0:D2}", DateTimeOriginal.Month),
String.Format("{0:D2}", DateTimeOriginal.Day));
Directory.CreateDirectory(directory);
Directory.CreateDirectory(Path.Combine(directory, "jpg"));
string baseFilename = Path.GetFileName(Filename);
string rawFilename = Path.ChangeExtension(Filename, "cr3");
// FIXME: if there's a JPG but not a RAW, make a "RAW" that's the
// camera's original JPG converted to something lossless (PNG? TIFF?).
if (Path.Exists(rawFilename)) {
string rawOut = Path.Combine(directory, Path.GetFileName(rawFilename));
if (!Path.Exists(rawOut)) {
System.IO.File.Copy(rawFilename, rawOut);
toast.Set($"{rawFilename} => {rawOut}");
}
}
using (Image<Rgba32> image = await Image.LoadAsync<Rgba32>(Filename)) {
Util.RotateImageFromExif(image, Orientation);
ExifProfile exif = image.Metadata.ExifProfile ?? new();
exif.SetValue<ushort>(ExifTag.Orientation, 1);
exif.SetValue<string>(ExifTag.Artist, "Colin McMillen");
exif.SetValue<string>(ExifTag.Copyright, "Colin McMillen");
exif.SetValue<string>(ExifTag.Software, "Totte");
exif.SetValue<ushort>(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<string>(ExifTag.DateTime, datetime);
if (Gps != null) {
GpsInfo gps = (GpsInfo) Gps;
exif.SetValue<byte[]>(ExifTag.GPSVersionID, gps.VersionId);
exif.SetValue<string>(ExifTag.GPSStatus, gps.Status);
exif.SetValue<string>(ExifTag.GPSDateStamp, gps.Datestamp);
exif.SetValue<Rational[]>(ExifTag.GPSTimestamp, gps.Timestamp);
exif.SetValue<Rational[]>(ExifTag.GPSLatitude, gps.Latitude);
exif.SetValue<string>(ExifTag.GPSLatitudeRef, gps.LatitudeRef);
exif.SetValue<Rational[]>(ExifTag.GPSLongitude, gps.Longitude);
exif.SetValue<string>(ExifTag.GPSLongitudeRef, gps.LongitudeRef);
exif.SetValue<Rational>(ExifTag.GPSAltitude, gps.Altitude);
exif.SetValue<byte>(ExifTag.GPSAltitudeRef, gps.AltitudeRef);
}
image.Metadata.XmpProfile = UpdateXmp(image.Metadata.XmpProfile);
if (RotationDegreeHundredths != 0) {
image.Mutate(x => x.Rotate(RotationDegreeHundredths / 100f));
}
if (CropRectangle != Rectangle.Empty) {
image.Mutate(x => x.Crop(CropRectangle));
}
string jpgOut = Path.Combine(directory, "jpg", baseFilename);
await image.SaveAsync(jpgOut, new JpegEncoder() { Quality = 100 });
toast.Set($"{Filename} => {jpgOut}");
// await image.SaveAsync(editOut, new PngEncoder() {
// BitDepth = PngBitDepth.Bit8, ChunkFilter = PngChunkFilter.None, ColorType = PngColorType.Rgb,
// CompressionLevel = PngCompressionLevel.BestCompression, FilterMethod = PngFilterMethod.Adaptive,
// InterlaceMethod = PngInterlaceMode.None });
}
}
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("</xmp:Rating>");
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;
}
if (exifs.TryGetValue(ExifTag.Orientation, out var orientation)) {
Orientation = orientation.Value;
}
if (exifs.TryGetValue(ExifTag.Model, out var model)) {
CameraModel = model.Value ?? "";
}
if (exifs.TryGetValue(ExifTag.LensModel, out var lensModel)) {
LensModel = lensModel.Value ?? "";
ShortLensModel = GetShortLensModel(LensModel);
}
if (exifs.TryGetValue(ExifTag.FocalLength, out var focalLength)) {
Rational r = focalLength.Value;
FocalLength = $"{r.Numerator / r.Denominator}mm";
}
if (exifs.TryGetValue(ExifTag.FNumber, out var 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 ExposureProgram.
if (exifs.TryGetValue(ExifTag.ExposureTime, out var 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();
}
}
if (exifs.TryGetValue(ExifTag.ISOSpeedRatings, out var 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: I think the iPhone stores time in UTC but other cameras report it in local time.
if (exifs.TryGetValue(ExifTag.DateTimeOriginal, out var 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}");
}
}
if (exifs.TryGetValue(ExifTag.SubsecTimeOriginal, out var subsecTimeOriginal)) {
double fractionalSeconds;
Double.TryParse("0." + subsecTimeOriginal.Value, out fractionalSeconds);
DateTimeOriginal = DateTimeOriginal.AddSeconds(fractionalSeconds);
}
if (exifs.TryGetValue(ExifTag.ExposureBiasValue, out var exposureBiasValue)) {
SignedRational r = exposureBiasValue.Value;
ExposureBiasValue = r.ToString();
if (r.Numerator >= 0) {
ExposureBiasValue = "+" + ExposureBiasValue;
}
}
Gps = GpsInfo.ParseExif(exifs);
}
public string GetShortLensModel(string lensModel) {
// Example Canon RF lens names:
// RF16mm F2.8 STM
// RF24-105mm F4-7.1 IS STM
// RF35mm F1.8 MACRO IS STM
// RF100-400mm F5.6-8 IS USM
string[] tokens = lensModel.Split(' ');
string result = "";
foreach (string token in tokens) {
if (token == "STM" || token == "IS" || token == "USM") {
continue;
}
result += token + " ";
}
return result.Trim();
}
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(image);
image.Dispose();
image = null;
Loaded = true;
}
return texture != placeholder ? texture : thumbnailTexture;
}
public Texture ThumbnailTexture() {
if (thumbnailTexture == placeholder && thumbnail != null) {
thumbnailTexture = new(thumbnail);
thumbnail.Dispose();
thumbnail = null;
}
return thumbnailTexture;
}
public string Description() {
string date = DateTimeOriginal.ToString("yyyy-MM-dd HH:mm:ss.ff");
return String.Format(
"{0,6} {1,-5} {2,-7} {3,-10} EV {9,-4} {7,4}x{8,-4} {4} {5,-20} {6}",
FocalLength, FNumber, ExposureTime, IsoSpeed, date, ShortLensModel, Filename, Size.X, Size.Y, ExposureBiasValue);
}
}

1569
Program.cs

File diff suppressed because it is too large Load Diff

118
Shader.cs Normal file
View File

@ -0,0 +1,118 @@
using OpenTK.Graphics.OpenGL4;
namespace SemiColinGames;
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);
}
}

55
Texture.cs Normal file
View File

@ -0,0 +1,55 @@
using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
using System.Runtime.CompilerServices;
namespace SemiColinGames;
public class Texture : IDisposable {
public int Handle;
public Vector2i Size;
private static int maxHandle = -1;
private bool disposedValue = false;
public Texture(Image<Rgba32> image) {
Size = new Vector2i(image.Width, image.Height);
byte[] pixelBytes = new byte[Size.X * Size.Y * Unsafe.SizeOf<Rgba32>()];
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);
}
}

View File

@ -2,14 +2,16 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTK" Version="4.7.7" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta15" />
</ItemGroup>
</Project>

4
package_debug_zip.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
revision=r$(git log | egrep "^commit [0-9a-f]{40}$" | wc -l)
mkdir totte-$revision && cp -r bin/Debug/net7.0/* totte-$revision/ && zip -r totte-$revision.zip totte-$revision/ && rm -rf totte-$revision