From 47741faaae84ef59cfe844cb731ef0107cae2c80 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 7 Dec 2025 16:55:09 +1000 Subject: [PATCH] Fix frame jiggle and improve screenshot quality - Add wait for frame 0 tiles to load before calculating bounding box - Preload all frame images on client to prevent layout shifts - Use absolute positioning for radar images to prevent reflow - Add 2x device scale factor for higher resolution screenshots - Explicit PNG format and disable animations for better quality - Wait for fonts to load before capturing screenshots --- Services/BrowserService.cs | 1 + Services/ScrapingService.cs | 50 +++++++++++++++++++----------------- Views/RadarTest/Index.cshtml | 32 ++++++++++++++++++++--- docker-compose.yml | 2 +- 4 files changed, 57 insertions(+), 28 deletions(-) diff --git a/Services/BrowserService.cs b/Services/BrowserService.cs index 84780d2..715ffca 100644 --- a/Services/BrowserService.cs +++ b/Services/BrowserService.cs @@ -95,6 +95,7 @@ public class BrowserService : IBrowserService return await browser.NewContextAsync(new BrowserNewContextOptions { ViewportSize = new ViewportSize { Width = 1920, Height = 1080 }, + DeviceScaleFactor = 2.0f, // 2x scale for higher resolution screenshots (better text quality) UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", Locale = "en-AU", TimezoneId = _timezone, diff --git a/Services/ScrapingService.cs b/Services/ScrapingService.cs index e2c4f5a..5f655a3 100644 --- a/Services/ScrapingService.cs +++ b/Services/ScrapingService.cs @@ -414,12 +414,18 @@ public class ScrapingService : IScrapingService await _debugService.SaveStepDebugAsync(debugFolder, 10, "scrubber_at_position_0", page, consoleMessages, networkRequests, cancellationToken); - // Step 11: Extract metadata and frame information + // Step 11: Wait for frame 0 tiles to fully load before calculating bounding box + // This ensures the map viewport is stable and prevents jiggle between frames + _logger.LogInformation("Waiting for frame 0 tiles to fully render"); + await page.WaitForTimeoutAsync(_tileRenderWaitMs); + + // Step 12: Extract metadata and frame information _logger.LogInformation("Extracting metadata and frame information"); var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(page); var frameInfo = await ExtractFrameInfoAsync(page); - // Step 12: Get map container and calculate bounding box once + // Step 13: Get map container and calculate bounding box once + // Calculate after frame 0 tiles are loaded to ensure consistent viewport _logger.LogInformation("Preparing map container for screenshot"); var mapContainer = page.Locator(".esri-view-surface").First; await mapContainer.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000 }); @@ -445,11 +451,11 @@ public class ScrapingService : IScrapingService Height = boundingBox.Height }; - // Step 13: Use provided cache folder (already created by BomRadarService) + // Step 14: Use provided cache folder (already created by BomRadarService) Directory.CreateDirectory(cacheFolderPath); _logger.LogInformation("Using cache folder: {Path}", cacheFolderPath); - // Step 14-20: Capture all 7 frames + // Step 15-21: Capture all 7 frames var frames = new List(); var stepForwardButton = page.Locator("button[data-testid='bom-scrub-utils__right__step-forward']").First; @@ -485,7 +491,7 @@ public class ScrapingService : IScrapingService frameIndex, framePath, minutesAgo.Value); // Save debug screenshot BEFORE clicking step forward - await _debugService.SaveStepDebugAsync(debugFolder, 14 + frameIndex, $"frame_{frameIndex}_captured", page, consoleMessages, networkRequests, cancellationToken); + await _debugService.SaveStepDebugAsync(debugFolder, 15 + frameIndex, $"frame_{frameIndex}_captured", page, consoleMessages, networkRequests, cancellationToken); // If not the last frame, click step forward to prepare for next frame if (frameIndex < 6) @@ -521,26 +527,11 @@ public class ScrapingService : IScrapingService _logger.LogInformation("All 7 frames captured successfully"); - // Step 21: Save metadata and frame information + // Step 22: Save metadata and frame information await _cacheService.SaveMetadataAsync(cacheFolderPath, lastUpdatedInfo, cancellationToken); await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, frames, cancellationToken); - - // Remove lock file to indicate cache folder is complete - var lockFilePath = FilePathHelper.GetCacheLockFilePath(cacheFolderPath); - try - { - if (File.Exists(lockFilePath)) - { - File.Delete(lockFilePath); - _logger.LogDebug("Removed cache lock file: {Path}", lockFilePath); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to remove cache lock file: {Path}", lockFilePath); - } - // Step 22: Return response with all frames + // Step 23: Return response with all frames // Note: ScrapingService doesn't have access to cache management check interval // Default to 5 minutes (standard check interval) return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: null, isUpdating: false, cacheManagementCheckIntervalMinutes: 5); @@ -728,10 +719,23 @@ public class ScrapingService : IScrapingService cropArea = containerClip; } + // Wait for fonts to be loaded to prevent text rendering artifacts + try + { + await page.WaitForLoadStateAsync(LoadState.NetworkIdle, new PageWaitForLoadStateOptions { Timeout = 5000 }); + } + catch + { + // Continue if network idle timeout - fonts may already be loaded + } + + // Take high-quality screenshot with explicit PNG format and disabled animations await page.ScreenshotAsync(new PageScreenshotOptions { Path = outputPath, - Clip = cropArea + Clip = cropArea, + Type = ScreenshotType.Png, // Explicit PNG for lossless quality + Animations = ScreenshotAnimations.Disabled // Disable animations to prevent artifacts }); _logger.LogDebug("Screenshot saved: {Path} (crop: {X},{Y} {Width}x{Height})", diff --git a/Views/RadarTest/Index.cshtml b/Views/RadarTest/Index.cshtml index d04457a..ff6e61a 100644 --- a/Views/RadarTest/Index.cshtml +++ b/Views/RadarTest/Index.cshtml @@ -80,13 +80,18 @@ overflow: hidden; margin-bottom: 20px; aspect-ratio: 16/9; + min-height: 400px; /* Prevent layout shift */ } .radar-image { + position: absolute; + top: 0; + left: 0; width: 100%; height: 100%; object-fit: contain; display: block; + image-rendering: crisp-edges; /* Prevent blurry scaling */ } .loading { @@ -732,6 +737,9 @@ `).join(''); + // Preload all frame images to prevent jiggle when switching + preloadImages(frames); + // Show first frame if (frames.length > 0) { showFrame(0); @@ -745,6 +753,14 @@ updateRelativeTimes(); } + // Preload all frame images to prevent jiggle + function preloadImages(frames) { + frames.forEach(frame => { + const img = new Image(); + img.src = frame.imageUrl; + }); + } + // Show specific frame function showFrame(index) { if (index < 0 || index >= frames.length) return; @@ -756,13 +772,21 @@ const imgEl = document.getElementById('radar-image'); const loadingEl = document.getElementById('loading'); - imgEl.onload = () => { + // Check if image is already loaded (from preload) + const preloadedImg = new Image(); + preloadedImg.onload = () => { + // Image is ready, switch immediately to prevent jiggle + imgEl.src = frame.imageUrl; + imgEl.alt = `Radar frame ${frame.frameIndex} (${frame.minutesAgo} minutes ago)`; loadingEl.style.display = 'none'; imgEl.style.display = 'block'; }; - - imgEl.src = frame.imageUrl; - imgEl.alt = `Radar frame ${frame.frameIndex} (${frame.minutesAgo} minutes ago)`; + preloadedImg.onerror = () => { + // Fallback if preload failed + imgEl.src = frame.imageUrl; + imgEl.alt = `Radar frame ${frame.frameIndex} (${frame.minutesAgo} minutes ago)`; + }; + preloadedImg.src = frame.imageUrl; // Update frame info document.getElementById('frame-info').textContent = diff --git a/docker-compose.yml b/docker-compose.yml index 3446d29..073040c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: - CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1} - SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000} - SCREENSHOT__TILERENDERWAITMS=${SCREENSHOT__TILERENDERWAITMS:-5000} - - DEBUG__ENABLED=${DEBUG__ENABLED:-false} + - DEBUG__ENABLED=${DEBUG__ENABLED:-true} - DEBUG__WAITMS=${DEBUG__WAITMS:-2000} - TIMEZONE=${TIMEZONE:-Australia/Brisbane} shm_size: '1gb'