From 0e4ecb3331a750f0ff7e92480b77532459806277 Mon Sep 17 00:00:00 2001 From: iTob Date: Fri, 3 Jul 2026 15:00:39 +0200 Subject: [PATCH] "Refactor LiveView and add new Capture features: Remove TimerControl and introduce Capture components (PhotoCompositor, CaptureMode, PhotoCaptureCoordinator, GifEncoder) with enhanced functionality, including photo strip compositing, GIF creation, and burst capture handling." --- src/CamBooth/CamBooth.App/App.xaml.cs | 2 + .../Core/AppSettings/AppSettingsService.cs | 57 ++- .../Core/AppSettings/app.settings.json | 16 +- .../Features/Camera/CameraService.cs | 25 +- .../Features/Capture/CaptureMode.cs | 21 + .../Features/Capture/GifEncoder.cs | 180 +++++++++ .../Capture/PhotoCaptureCoordinator.cs | 187 +++++++++ .../Features/Capture/PhotoCompositor.cs | 144 +++++++ .../Features/LiveView/TimerControl.xaml | 29 -- .../Features/LiveView/TimerControl.xaml.cs | 44 --- src/CamBooth/CamBooth.App/MainWindow.xaml | 125 +++--- src/CamBooth/CamBooth.App/MainWindow.xaml.cs | 366 +++++++++++++++--- .../CamBooth.App/MainWindowViewModel.cs | 8 +- 13 files changed, 992 insertions(+), 212 deletions(-) create mode 100644 src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs create mode 100644 src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs create mode 100644 src/CamBooth/CamBooth.App/Features/Capture/PhotoCaptureCoordinator.cs create mode 100644 src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs delete mode 100644 src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml delete mode 100644 src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml.cs diff --git a/src/CamBooth/CamBooth.App/App.xaml.cs b/src/CamBooth/CamBooth.App/App.xaml.cs index 760a55b..c8b9beb 100644 --- a/src/CamBooth/CamBooth.App/App.xaml.cs +++ b/src/CamBooth/CamBooth.App/App.xaml.cs @@ -2,6 +2,7 @@ using System.Windows; using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.Logging; using CamBooth.App.Features.Camera; +using CamBooth.App.Features.Capture; using CamBooth.App.Features.PhotoPrismUpload; using CamBooth.App.Features.PictureGallery; using EDSDKLib.API.Base; @@ -89,6 +90,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs b/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs index f159c97..d8d92e9 100644 --- a/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs +++ b/src/CamBooth/CamBooth.App/Core/AppSettings/AppSettingsService.cs @@ -44,7 +44,7 @@ public class AppSettingsService public string? AppName => configuration["AppSettings:AppName"]; - public bool IsDebugConsoleVisible => bool.Parse(configuration["AppSettings:DebugConsoleVisible"] ?? string.Empty); + public bool IsDebugConsoleVisible => bool.Parse(configuration["AppSettings:DebugConsoleVisible"] ?? "false"); public string? PictureLocation => configuration["AppSettings:PictureLocation"]; @@ -58,6 +58,50 @@ public class AppSettingsService public bool UseMockCamera => bool.Parse(configuration["AppSettings:UseMockCamera"] ?? "false"); + // Sekunden ohne Interaktion, bis der Idle-/Attract-Screen (Slideshow) erscheint. 0 = deaktiviert. + public int IdleTimeoutSeconds => int.Parse(configuration["AppSettings:IdleTimeoutSeconds"] ?? "60"); + + // Zeigt nach der Aufnahme das Foto mit "Behalten / Nochmal". + public bool IsReviewEnabled => bool.Parse(configuration["AppSettings:ReviewEnabled"] ?? "true"); + + // Sekunden, nach denen die Review automatisch mit "Behalten" bestätigt wird. + public int ReviewTimeoutSeconds => int.Parse(configuration["AppSettings:ReviewTimeoutSeconds"] ?? "6"); + + // Aufnahmemodus: "Single" (ein Foto), "Strip" (Fotostreifen), "Gif" (animiertes GIF). + public string CaptureMode => configuration["AppSettings:CaptureMode"] ?? "Single"; + + // Anzahl der Aufnahmen für Strip/Gif. + public int BurstCount => int.Parse(configuration["AppSettings:BurstCount"] ?? "4"); + + // Pause zwischen den Einzelaufnahmen eines Bursts (ms). + public int BurstIntervalMs => int.Parse(configuration["AppSettings:BurstIntervalMs"] ?? "900"); + + // Text in der Fußzeile des Fotostreifens. Leer = keine Fußzeile. + // Platzhalter {date} wird durch das heutige Datum ersetzt. + public string StripFooterText => configuration["AppSettings:StripFooterText"] ?? "CamBooth"; + + // Optionaler Logo-Pfad. Wenn gesetzt und lesbar, ersetzt das Logo den Fußzeilen-Text. + public string? StripLogoPath => configuration["AppSettings:StripLogoPath"]; + + // Textfarbe der Fußzeile (WPF-Farbstring, z. B. "#D4AF37"). + public string StripFooterColor => configuration["AppSettings:StripFooterColor"] ?? "#D4AF37"; + + // Hintergrundfarbe des Streifens (z. B. "#FFFFFF"). + public string StripBackgroundColor => configuration["AppSettings:StripBackgroundColor"] ?? "#FFFFFF"; + + // Schriftart der Fußzeile. + public string StripFooterFont => configuration["AppSettings:StripFooterFont"] ?? "Segoe UI"; + + // Schriftgröße der Fußzeile. + public double StripFooterFontSize => + double.Parse(configuration["AppSettings:StripFooterFontSize"] ?? "34", System.Globalization.CultureInfo.InvariantCulture); + + // Höhe der Fußzeile in Pixeln (bei Logo ggf. erhöhen). + public int StripFooterHeight => int.Parse(configuration["AppSettings:StripFooterHeight"] ?? "90"); + + // Konfetti-Animation nach erfolgreicher Aufnahme. + public bool IsConfettiEnabled => bool.Parse(configuration["AppSettings:ConfettiEnabled"] ?? "true"); + public string? ConnectionString => configuration.GetConnectionString("DefaultConnection"); public string ConfigFileName => loadedConfigFile; @@ -71,17 +115,6 @@ public class AppSettingsService public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"]; - // Lychee Upload Settings (deprecated - wird durch PhotoPrism ersetzt) - public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"]; - - public string? LycheeUsername => configuration["LycheeSettings:Username"]; - - public string? LycheePassword => configuration["LycheeSettings:Password"]; - - public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"]; - - public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false"); - // PhotoPrism Upload Settings public string? PhotoPrismApiUrl => configuration["PhotoPrismSettings:ApiUrl"]; diff --git a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json index f80d8e7..97fbb90 100644 --- a/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json +++ b/src/CamBooth/CamBooth.App/Core/AppSettings/app.settings.json @@ -9,7 +9,21 @@ "FocusDelaySeconds": 2, "FocusTimeoutMs": 3000, "IsShutdownEnabled": false, - "UseMockCamera": false + "UseMockCamera": false, + "IdleTimeoutSeconds": 60, + "ReviewEnabled": true, + "ReviewTimeoutSeconds": 6, + "CaptureMode": "Single", + "BurstCount": 4, + "BurstIntervalMs": 900, + "StripFooterText": "CamBooth · {date}", + "StripLogoPath": "", + "StripFooterColor": "#D4AF37", + "StripBackgroundColor": "#FFFFFF", + "StripFooterFont": "Segoe UI", + "StripFooterFontSize": 34, + "StripFooterHeight": 90, + "ConfettiEnabled": true }, "Serilog": { "MinimumLevel": { diff --git a/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs b/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs index c1f9f12..78562b0 100644 --- a/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs +++ b/src/CamBooth/CamBooth.App/Features/Camera/CameraService.cs @@ -4,8 +4,6 @@ using System.Threading.Tasks; using System.Windows; using CamBooth.App.Core.AppSettings; using CamBooth.App.Core.Logging; -using CamBooth.App.Features.PhotoPrismUpload; -using CamBooth.App.Features.PictureGallery; using EOSDigital.API; using EOSDigital.SDK; @@ -15,8 +13,6 @@ public class CameraService : IDisposable { private readonly AppSettingsService _appSettings; private readonly Logger _logger; - private readonly PictureGalleryService _pictureGalleryService; - private readonly PhotoPrismUploadQueueService _photoPrismUploadQueueService; private readonly ICanonAPI _canonApi; private ICamera? _mainCamera; @@ -27,20 +23,23 @@ public class CameraService : IDisposable /// Fires whenever the camera delivers a new live-view frame. public event Action? LiveViewUpdated; + /// + /// Fires (on the UI dispatcher) after a captured photo has been downloaded and saved to disk. + /// The argument is the full path of the saved file. Subscribers decide what to do with it + /// (review, gallery refresh, upload) — the service no longer auto-queues uploads. + /// + public event Action? PhotoSaved; + public bool IsConnected => _isConnected && _mainCamera?.SessionOpen == true; public CameraService( Logger logger, AppSettingsService appSettings, - PictureGalleryService pictureGalleryService, - PhotoPrismUploadQueueService photoPrismUploadQueueService, ICanonAPI canonApi) { _logger = logger; _appSettings = appSettings; - _pictureGalleryService = pictureGalleryService; - _photoPrismUploadQueueService = photoPrismUploadQueueService; _canonApi = canonApi; } @@ -292,13 +291,9 @@ public class CameraService : IDisposable var savedPath = Path.Combine(_appSettings.PictureLocation!, info.FileName); _logger.Debug($"Download complete: {savedPath}"); - Application.Current.Dispatcher.Invoke(() => - { - _pictureGalleryService.IncrementNewPhotoCount(); - _pictureGalleryService.LoadThumbnailsToCache(); - }); - - _photoPrismUploadQueueService.QueueNewPhoto(savedPath); + // Notify subscribers on the UI thread. Gallery refresh and upload queueing are + // now the subscriber's responsibility (so e.g. a rejected photo isn't uploaded). + Application.Current.Dispatcher.Invoke(() => PhotoSaved?.Invoke(savedPath)); } catch (Exception ex) { diff --git a/src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs b/src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs new file mode 100644 index 0000000..c0f3d58 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs @@ -0,0 +1,21 @@ +namespace CamBooth.App.Features.Capture; + +/// How a photo session captures: a single shot, a photo strip, or an animated GIF. +public enum CaptureMode +{ + Single, + Strip, + Gif +} + +/// Result of a capture session — what to show/keep plus the raw frames for cleanup. +public sealed class CaptureResult +{ + /// The file to present and (on keep) add to the gallery — a single photo, a strip, or a GIF. + public required string ResultPath { get; init; } + + /// The raw individual frames captured. For Single this equals [ResultPath]. + public required IReadOnlyList FramePaths { get; init; } + + public CaptureMode Mode { get; init; } +} diff --git a/src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs b/src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs new file mode 100644 index 0000000..b26e22d --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs @@ -0,0 +1,180 @@ +using System.IO; +using System.Text; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace CamBooth.App.Features.Capture; + +/// +/// Creates an animated GIF from several frames using WPF's . +/// WPF writes the frames but not the per-frame delay or the loop flag, so the encoded bytes are +/// post-processed to inject a NETSCAPE2.0 looping block and a Graphic Control Extension per frame. +/// +public static class GifEncoder +{ + private const int TargetWidth = 480; + + /// + /// Builds an animated, looping GIF from and writes it to + /// . is the per-frame display time. + /// + public static string CreateGif(IReadOnlyList framePaths, string outputPath, int frameDelayMs = 600) + { + if (framePaths.Count == 0) + throw new ArgumentException("No frames for GIF.", nameof(framePaths)); + + var (w, h) = TargetSize(framePaths[0]); + + var encoder = new GifBitmapEncoder(); + foreach (var path in framePaths) + encoder.Frames.Add(BitmapFrame.Create(ToUniformFrame(path, w, h))); + + byte[] raw; + using (var ms = new MemoryStream()) + { + encoder.Save(ms); + raw = ms.ToArray(); + } + + var delayCs = (byte)Math.Clamp(frameDelayMs / 10, 2, 255); + var bytes = TryAddAnimation(raw, delayCs); + + File.WriteAllBytes(outputPath, bytes); + return outputPath; + } + + /// + /// Rewrites a static multi-frame GIF into a looping animation. On any parsing surprise it falls + /// back to the original bytes (still a valid GIF), so a capture is never lost. + /// + private static byte[] TryAddAnimation(byte[] src, byte delayCs) + { + try + { + var outBytes = new List(src.Length + 64); + int pos = 0; + + // Header (6) + Logical Screen Descriptor (7) + outBytes.AddRange(src[..13]); + pos = 13; + + // Global Color Table (if present) + var lsdPacked = src[10]; + if ((lsdPacked & 0x80) != 0) + { + int gctBytes = 3 * (1 << ((lsdPacked & 0x07) + 1)); + outBytes.AddRange(src[pos..(pos + gctBytes)]); + pos += gctBytes; + } + + // Insert the loop-forever application extension once. + outBytes.AddRange(NetscapeLoopBlock()); + + while (pos < src.Length) + { + byte block = src[pos]; + + if (block == 0x3B) // trailer + { + outBytes.Add(block); + pos++; + break; + } + + if (block == 0x21) // extension + { + outBytes.Add(src[pos++]); // introducer + outBytes.Add(src[pos++]); // label + pos = CopySubBlocks(src, pos, outBytes); + } + else if (block == 0x2C) // image descriptor + { + outBytes.AddRange(GraphicControlExtension(delayCs)); // our delay, before the image + + outBytes.AddRange(src[pos..(pos + 10)]); // descriptor (separator + 9) + byte imgPacked = src[pos + 9]; + pos += 10; + + if ((imgPacked & 0x80) != 0) // local color table + { + int lctBytes = 3 * (1 << ((imgPacked & 0x07) + 1)); + outBytes.AddRange(src[pos..(pos + lctBytes)]); + pos += lctBytes; + } + + outBytes.Add(src[pos++]); // LZW minimum code size + pos = CopySubBlocks(src, pos, outBytes); // image data + } + else + { + // Unknown — bail out and keep the original (safe fallback). + return src; + } + } + + return outBytes.ToArray(); + } + catch + { + return src; + } + } + + private static int CopySubBlocks(byte[] src, int pos, List outBytes) + { + while (true) + { + byte size = src[pos]; + outBytes.Add(size); + pos++; + if (size == 0) break; + outBytes.AddRange(src[pos..(pos + size)]); + pos += size; + } + return pos; + } + + private static byte[] NetscapeLoopBlock() + { + var b = new List { 0x21, 0xFF, 0x0B }; + b.AddRange(Encoding.ASCII.GetBytes("NETSCAPE2.0")); + b.AddRange(new byte[] { 0x03, 0x01, 0x00, 0x00, 0x00 }); // sub-block, id=1, loop count 0 = forever, terminator + return b.ToArray(); + } + + private static byte[] GraphicControlExtension(byte delayCs) => + // introducer, label, block size, packed (disposal=1), delay lo/hi, transparent index, terminator + new byte[] { 0x21, 0xF9, 0x04, 0x04, delayCs, 0x00, 0x00, 0x00 }; + + private static (int w, int h) TargetSize(string firstFramePath) + { + var probe = new BitmapImage(); + probe.BeginInit(); + probe.UriSource = new Uri(firstFramePath); + probe.CacheOption = BitmapCacheOption.OnLoad; + probe.EndInit(); + int h = (int)Math.Round(TargetWidth * (probe.PixelHeight / (double)probe.PixelWidth)); + return (TargetWidth, Math.Max(1, h)); + } + + private static BitmapSource ToUniformFrame(string path, int w, int h) + { + var src = new BitmapImage(); + src.BeginInit(); + src.UriSource = new Uri(path); + src.CacheOption = BitmapCacheOption.OnLoad; + src.DecodePixelWidth = w; + src.EndInit(); + src.Freeze(); + + var visual = new DrawingVisual(); + using (var dc = visual.RenderOpen()) + dc.DrawImage(src, new Rect(0, 0, w, h)); + + var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); + rtb.Render(visual); + rtb.Freeze(); + return rtb; + } +} diff --git a/src/CamBooth/CamBooth.App/Features/Capture/PhotoCaptureCoordinator.cs b/src/CamBooth/CamBooth.App/Features/Capture/PhotoCaptureCoordinator.cs new file mode 100644 index 0000000..c68b883 --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/Capture/PhotoCaptureCoordinator.cs @@ -0,0 +1,187 @@ +using System.IO; +using CamBooth.App.Core.AppSettings; +using CamBooth.App.Core.Logging; +using CamBooth.App.Features.Camera; +using CamBooth.App.Features.PhotoPrismUpload; +using CamBooth.App.Features.PictureGallery; + +namespace CamBooth.App.Features.Capture; + +/// +/// Orchestrates a capture session: one shot for , or a timed burst +/// composited into a strip / animated GIF. Keeps the file/gallery/upload concerns out of the UI. +/// +public sealed class PhotoCaptureCoordinator +{ + private const int DownloadTimeoutMs = 15_000; + + private readonly Logger _logger; + private readonly AppSettingsService _appSettings; + private readonly CameraService _cameraService; + private readonly PictureGalleryService _pictureGalleryService; + private readonly PhotoPrismUploadQueueService _uploadQueueService; + + private TaskCompletionSource? _pendingShot; + private bool _capturing; + + /// Raised (UI thread) at the moment each shot is taken — drive the shutter flash here. + public event Action? ShutterFlashRequested; + + /// Raised (UI thread) before each burst shot: (currentShot, totalShots). + public event Action? BurstProgressChanged; + + public PhotoCaptureCoordinator( + Logger logger, + AppSettingsService appSettings, + CameraService cameraService, + PictureGalleryService pictureGalleryService, + PhotoPrismUploadQueueService uploadQueueService) + { + _logger = logger; + _appSettings = appSettings; + _cameraService = cameraService; + _pictureGalleryService = pictureGalleryService; + _uploadQueueService = uploadQueueService; + + _cameraService.PhotoSaved += OnPhotoSaved; + } + + /// + /// Runs a full capture session and returns the result to present, or null if it failed + /// (e.g. the camera never delivered a photo). + /// + public async Task CaptureAsync() + { + if (_capturing) + { + _logger.Warning("Capture already in progress — ignoring re-entrant request."); + return null; + } + + _capturing = true; + try + { + var mode = ParseMode(_appSettings.CaptureMode); + int count = mode == CaptureMode.Single ? 1 : Math.Max(1, _appSettings.BurstCount); + + var frames = new List(); + for (int i = 0; i < count; i++) + { + BurstProgressChanged?.Invoke(i + 1, count); + frames.Add(await CaptureOneAsync(DownloadTimeoutMs)); + if (i < count - 1) + await Task.Delay(_appSettings.BurstIntervalMs); + } + + if (mode == CaptureMode.Single) + return new CaptureResult { ResultPath = frames[0], FramePaths = frames, Mode = CaptureMode.Single }; + + return Composite(mode, frames); + } + catch (Exception ex) + { + _logger.Error($"Capture failed: {ex.Message}"); + return null; + } + finally + { + _capturing = false; + } + } + + /// Accepts the result: adds it to the gallery and queues the upload. + public void Keep(CaptureResult result) + { + try + { + _pictureGalleryService.IncrementNewPhotoCount(); + _ = _pictureGalleryService.LoadThumbnailsToCache(); + _uploadQueueService.QueueNewPhoto(result.ResultPath); + } + catch (Exception ex) + { + _logger.Error($"Failed to keep photo: {ex.Message}"); + } + } + + /// Rejects the result: deletes the produced file(s) so nothing is shown or uploaded. + public void Discard(CaptureResult result) + { + var paths = new List(result.FramePaths) { result.ResultPath }; + foreach (var path in paths.Distinct()) + TryDelete(path); + } + + private CaptureResult Composite(CaptureMode mode, List frames) + { + var location = _appSettings.PictureLocation!; + try + { + string outPath; + if (mode == CaptureMode.Strip) + { + outPath = Path.Combine(location, $"strip_{Guid.NewGuid()}.jpg"); + PhotoCompositor.CreateStrip(frames, outPath, BuildBranding()); + } + else + { + outPath = Path.Combine(location, $"gif_{Guid.NewGuid()}.gif"); + GifEncoder.CreateGif(frames, outPath, _appSettings.BurstIntervalMs); + } + + // Raw frames are only inputs — keep just the composite in the gallery/upload. + foreach (var f in frames) + TryDelete(f); + + return new CaptureResult { ResultPath = outPath, FramePaths = Array.Empty(), Mode = mode }; + } + catch (Exception ex) + { + _logger.Error($"Compositing ({mode}) failed, falling back to single photo: {ex.Message}"); + for (int i = 1; i < frames.Count; i++) + TryDelete(frames[i]); + return new CaptureResult { ResultPath = frames[0], FramePaths = new[] { frames[0] }, Mode = CaptureMode.Single }; + } + } + + private StripBrandingOptions BuildBranding() => new() + { + FooterText = _appSettings.StripFooterText.Replace("{date}", DateTime.Now.ToString("dd.MM.yyyy")), + LogoPath = _appSettings.StripLogoPath, + FooterColorHex = _appSettings.StripFooterColor, + BackgroundColorHex = _appSettings.StripBackgroundColor, + FontFamily = _appSettings.StripFooterFont, + FontSize = _appSettings.StripFooterFontSize, + FooterHeight = _appSettings.StripFooterHeight + }; + + private async Task CaptureOneAsync(int timeoutMs) + { + _pendingShot = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ShutterFlashRequested?.Invoke(); + _cameraService.TakePhoto(); + + var completed = await Task.WhenAny(_pendingShot.Task, Task.Delay(timeoutMs)); + if (completed != _pendingShot.Task) + throw new TimeoutException("Camera did not deliver a photo in time."); + + return await _pendingShot.Task; + } + + private void OnPhotoSaved(string path) => _pendingShot?.TrySetResult(path); + + private void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + _logger.Warning($"Could not delete '{Path.GetFileName(path)}': {ex.Message}"); + } + } + + private static CaptureMode ParseMode(string value) => + Enum.TryParse(value, ignoreCase: true, out var mode) ? mode : CaptureMode.Single; +} diff --git a/src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs b/src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs new file mode 100644 index 0000000..a43d59f --- /dev/null +++ b/src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs @@ -0,0 +1,144 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace CamBooth.App.Features.Capture; + +/// +/// Builds a classic vertical photo strip (montage) from several captured frames. +/// Pure WPF imaging — no external dependencies. +/// +public static class PhotoCompositor +{ + private const int CellWidth = 600; // width each photo is scaled to (px) + private const int Border = 24; // white border / gap between photos (px) + private const double LogoPadding = 12; // padding around a footer logo (px) + + /// + /// Composites into a vertical strip and writes it as JPEG to + /// . Returns on success. + /// + public static string CreateStrip(IReadOnlyList framePaths, string outputPath, StripBrandingOptions branding) + { + if (framePaths.Count == 0) + throw new ArgumentException("No frames to composite.", nameof(framePaths)); + + var frames = framePaths.Select(LoadFrozen).ToList(); + + // Each photo keeps its aspect ratio, scaled to CellWidth. + var cellHeights = frames + .Select(f => (int)Math.Round(CellWidth * (f.PixelHeight / (double)f.PixelWidth))) + .ToList(); + + var logo = LoadLogo(branding.LogoPath); + var hasText = !string.IsNullOrWhiteSpace(branding.FooterText); + var hasFooter = logo != null || hasText; + var footerBand = hasFooter ? branding.FooterHeight : 0; + + var totalWidth = CellWidth + 2 * Border; + var totalHeight = Border + cellHeights.Sum() + Border * frames.Count + footerBand; + + var background = new SolidColorBrush(ParseColor(branding.BackgroundColorHex, Colors.White)); + + var visual = new DrawingVisual(); + using (var dc = visual.RenderOpen()) + { + dc.DrawRectangle(background, null, new Rect(0, 0, totalWidth, totalHeight)); + + double y = Border; + for (int i = 0; i < frames.Count; i++) + { + dc.DrawImage(frames[i], new Rect(Border, y, CellWidth, cellHeights[i])); + y += cellHeights[i] + Border; + } + + if (hasFooter) + { + var footerTop = totalHeight - branding.FooterHeight; + + if (logo != null) + DrawLogo(dc, logo, footerTop, totalWidth, branding.FooterHeight); + else + DrawFooterText(dc, visual, branding, footerTop, totalWidth); + } + } + + var rtb = new RenderTargetBitmap(totalWidth, totalHeight, 96, 96, PixelFormats.Pbgra32); + rtb.Render(visual); + + var encoder = new JpegBitmapEncoder { QualityLevel = 92 }; + encoder.Frames.Add(BitmapFrame.Create(rtb)); + + using var output = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + encoder.Save(output); + + return outputPath; + } + + private static void DrawFooterText(DrawingContext dc, DrawingVisual visual, StripBrandingOptions branding, double footerTop, int totalWidth) + { + var formatted = new FormattedText( + branding.FooterText!, + System.Globalization.CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(new FontFamily(branding.FontFamily), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal), + branding.FontSize, + new SolidColorBrush(ParseColor(branding.FooterColorHex, Color.FromRgb(0xD4, 0xAF, 0x37))), + VisualTreeHelper.GetDpi(visual).PixelsPerDip); + + var origin = new Point((totalWidth - formatted.Width) / 2, footerTop + (branding.FooterHeight - formatted.Height) / 2); + dc.DrawText(formatted, origin); + } + + private static void DrawLogo(DrawingContext dc, BitmapSource logo, double footerTop, int totalWidth, int footerHeight) + { + var maxHeight = footerHeight - 2 * LogoPadding; + var maxWidth = totalWidth - 2 * LogoPadding; + + var scale = Math.Min(maxHeight / logo.PixelHeight, maxWidth / logo.PixelWidth); + var w = logo.PixelWidth * scale; + var h = logo.PixelHeight * scale; + var x = (totalWidth - w) / 2; + var yy = footerTop + (footerHeight - h) / 2; + dc.DrawImage(logo, new Rect(x, yy, w, h)); + } + + private static BitmapSource? LoadLogo(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; + + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri(path); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } + + private static Color ParseColor(string hex, Color fallback) + { + try + { + var converted = ColorConverter.ConvertFromString(hex); + return converted is Color c ? c : fallback; + } + catch + { + return fallback; + } + } + + private static BitmapSource LoadFrozen(string path) + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri(path); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.DecodePixelWidth = CellWidth; + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } +} diff --git a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml deleted file mode 100644 index 5d8c1e8..0000000 --- a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml.cs b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml.cs deleted file mode 100644 index 9d4bede..0000000 --- a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControl.xaml.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Windows.Controls; -using System.Windows.Media.Animation; -using System.Windows.Threading; - -namespace CamBooth.App.Features.LiveView; - -public partial class ModernTimerControl : UserControl -{ - private DispatcherTimer _timer; - - private int _remainingTime; // Zeit in Sekunden - - - public ModernTimerControl() - { - InitializeComponent(); - InitializeTimer(); - } - - - private void InitializeTimer() - { - _timer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(1) - }; - _timer.Tick += Timer_Tick; - } - - - private void Timer_Tick(object sender, EventArgs e) - { - if (_remainingTime > 0) - { - _remainingTime--; - TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss"); - } - else - { - _timer.Stop(); - StatusText.Text = "Zeit abgelaufen!"; - } - } -} \ No newline at end of file diff --git a/src/CamBooth/CamBooth.App/MainWindow.xaml b/src/CamBooth/CamBooth.App/MainWindow.xaml index b464650..ca73025 100644 --- a/src/CamBooth/CamBooth.App/MainWindow.xaml +++ b/src/CamBooth/CamBooth.App/MainWindow.xaml @@ -50,6 +50,21 @@ + + + + @@ -339,52 +354,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + +