mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-01-27 09:26:04 +00:00
Multi-language / Translations Support (#3696) - Complete internationalization system implemented - Support for 7 languages: Czech (cs), German (de), French (fr), Italian (it), Korean (ko), Chinese Simplified (zh), Chinese Traditional (zh_TW) - Language selector with localized flags and theming - Flash message translations - Multiple translation fixes and improvements across all languages - Language setting preserved across redirects Pluggable Content Fetchers (#3653) - New architecture for extensible content fetcher system - Allows custom fetcher implementations Image / Screenshot Comparison Processor (#3680) - New processor for visual change detection (disabled for this release) - Supporting CSS/JS infrastructure added UI Improvements Design & Layout - Auto-generated tag color schemes - Simplified login form styling - Removed hard-coded CSS, moved to SCSS variables - Tag UI cleanup and improvements - Automatic tab wrapper functionality - Menu refactoring for better organization - Cleanup of offset settings - Hide sticky tabs on narrow viewports - Improved responsive layout (#3702) User Experience - Modal alerts/confirmations on delete/clear operations (#3693, #3598, #3382) - Auto-add https:// to URLs in quickwatch form if not present - Better redirect handling on login (#3699) - 'Recheck all' now returns to correct group/tag (#3673) - Language set redirect keeps hash fragment - More friendly human-readable text throughout UI Performance & Reliability Scheduler & Processing - Soft delays instead of blocking time.sleep() calls (#3710) - More resilient handling of same UUID being processed (#3700) - Better Puppeteer timeout handling - Improved Puppeteer shutdown/cleanup (#3692) - Requests cleanup now properly async History & Rendering - Faster server-side "difference" rendering on History page (#3442) - Show ignored/triggered rows in history - API: Retry watch data if watch dict changed (more reliable) API Improvements - Watch get endpoint: retry mechanism for changed watch data - WatchHistoryDiff API endpoint includes extra format args (#3703) Testing Improvements - Replace time.sleep with wait_for_notification_endpoint_output (#3716) - Test for mode switching (#3701) - Test for #3720 added (#3725) - Extract-text difference test fixes - Improved dev workflow Bug Fixes - Notification error text output (#3672, #3669, #3280) - HTML validation fixes (#3704) - Template discovery path fixes - Notification debug log now uses system locale for dates/times - Puppeteer spelling mistake in log output - Recalculation on anchor change - Queue bubble update disabled temporarily Dependency Updates - beautifulsoup4 updated (#3724) - psutil 7.1.0 → 7.2.1 (#3723) - python-engineio ~=4.12.3 → ~=4.13.0 (#3707) - python-socketio ~=5.14.3 → ~=5.16.0 (#3706) - flask-socketio ~=5.5.1 → ~=5.6.0 (#3691) - brotli ~=1.1 → ~=1.2 (#3687) - lxml updated (#3590) - pytest ~=7.2 → ~=9.0 (#3676) - jsonschema ~=4.0 → ~=4.25 (#3618) - pluggy ~=1.5 → ~=1.6 (#3616) - cryptography 44.0.1 → 46.0.3 (security) (#3589) Documentation - README updated with viewport size setup information Development Infrastructure - Dev container only built on dev branch - Improved dev workflow tooling
143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
# Pages with a vertical height longer than this will use the 'stitch together' method.
|
||
|
||
# - Many GPUs have a max texture size of 16384x16384px (or lower on older devices).
|
||
# - If a page is taller than ~8000–10000px, it risks exceeding GPU memory limits.
|
||
# - This is especially important on headless Chromium, where Playwright may fail to allocate a massive full-page buffer.
|
||
|
||
from loguru import logger
|
||
|
||
from changedetectionio.content_fetchers import SCREENSHOT_MAX_HEIGHT_DEFAULT, SCREENSHOT_DEFAULT_QUALITY
|
||
|
||
# Cache font to avoid loading on every stitch
|
||
_cached_font = None
|
||
|
||
def _get_caption_font():
|
||
"""Get or create cached font for caption text."""
|
||
global _cached_font
|
||
if _cached_font is None:
|
||
from PIL import ImageFont
|
||
try:
|
||
_cached_font = ImageFont.truetype("arial.ttf", 35)
|
||
except IOError:
|
||
_cached_font = ImageFont.load_default()
|
||
return _cached_font
|
||
|
||
|
||
def stitch_images_inline(chunks_bytes, original_page_height, capture_height):
|
||
"""
|
||
Stitch image chunks together inline (no multiprocessing).
|
||
Optimized for small number of chunks (2-3) to avoid process creation overhead.
|
||
|
||
Args:
|
||
chunks_bytes: List of JPEG image bytes
|
||
original_page_height: Original page height in pixels
|
||
capture_height: Maximum capture height
|
||
|
||
Returns:
|
||
bytes: Stitched JPEG image
|
||
"""
|
||
import os
|
||
import io
|
||
from PIL import Image, ImageDraw
|
||
|
||
# Load images from byte chunks
|
||
images = [Image.open(io.BytesIO(b)) for b in chunks_bytes]
|
||
total_height = sum(im.height for im in images)
|
||
max_width = max(im.width for im in images)
|
||
|
||
# Create stitched image
|
||
stitched = Image.new('RGB', (max_width, total_height))
|
||
y_offset = 0
|
||
for im in images:
|
||
stitched.paste(im, (0, y_offset))
|
||
y_offset += im.height
|
||
im.close() # Close immediately after pasting
|
||
|
||
# Draw caption only if page was trimmed
|
||
if original_page_height > capture_height:
|
||
draw = ImageDraw.Draw(stitched)
|
||
caption_text = f"WARNING: Screenshot was {original_page_height}px but trimmed to {capture_height}px because it was too long"
|
||
padding = 10
|
||
font = _get_caption_font()
|
||
|
||
bbox = draw.textbbox((0, 0), caption_text, font=font)
|
||
text_width = bbox[2] - bbox[0]
|
||
text_height = bbox[3] - bbox[1]
|
||
|
||
# Draw white background rectangle
|
||
draw.rectangle([(0, 0), (max_width, text_height + 2 * padding)], fill=(255, 255, 255))
|
||
|
||
# Draw text centered
|
||
text_x = (max_width - text_width) // 2
|
||
draw.text((text_x, padding), caption_text, font=font, fill=(255, 0, 0))
|
||
|
||
# Encode to JPEG
|
||
output = io.BytesIO()
|
||
stitched.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", SCREENSHOT_DEFAULT_QUALITY)), optimize=True)
|
||
result = output.getvalue()
|
||
|
||
# Cleanup
|
||
stitched.close()
|
||
|
||
return result
|
||
|
||
|
||
def stitch_images_worker(pipe_conn, chunks_bytes, original_page_height, capture_height):
|
||
"""
|
||
Stitch image chunks together in a separate process.
|
||
Used for large number of chunks (4+) to avoid blocking the main event loop.
|
||
"""
|
||
import os
|
||
import io
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
try:
|
||
# Load images from byte chunks
|
||
images = [Image.open(io.BytesIO(b)) for b in chunks_bytes]
|
||
total_height = sum(im.height for im in images)
|
||
max_width = max(im.width for im in images)
|
||
|
||
# Create stitched image
|
||
stitched = Image.new('RGB', (max_width, total_height))
|
||
y_offset = 0
|
||
for im in images:
|
||
stitched.paste(im, (0, y_offset))
|
||
y_offset += im.height
|
||
im.close() # Close immediately after pasting
|
||
|
||
# Draw caption only if page was trimmed
|
||
if original_page_height > capture_height:
|
||
draw = ImageDraw.Draw(stitched)
|
||
caption_text = f"WARNING: Screenshot was {original_page_height}px but trimmed to {capture_height}px because it was too long"
|
||
padding = 10
|
||
|
||
# Try to load font
|
||
try:
|
||
font = ImageFont.truetype("arial.ttf", 35)
|
||
except IOError:
|
||
font = ImageFont.load_default()
|
||
|
||
bbox = draw.textbbox((0, 0), caption_text, font=font)
|
||
text_width = bbox[2] - bbox[0]
|
||
text_height = bbox[3] - bbox[1]
|
||
|
||
# Draw white background rectangle
|
||
draw.rectangle([(0, 0), (max_width, text_height + 2 * padding)], fill=(255, 255, 255))
|
||
|
||
# Draw text centered
|
||
text_x = (max_width - text_width) // 2
|
||
draw.text((text_x, padding), caption_text, font=font, fill=(255, 0, 0))
|
||
|
||
# Encode and send image with optimization
|
||
output = io.BytesIO()
|
||
stitched.save(output, format="JPEG", quality=int(os.getenv("SCREENSHOT_QUALITY", SCREENSHOT_DEFAULT_QUALITY)), optimize=True)
|
||
pipe_conn.send_bytes(output.getvalue())
|
||
|
||
stitched.close()
|
||
except Exception as e:
|
||
pipe_conn.send(f"error:{e}")
|
||
finally:
|
||
pipe_conn.close()
|
||
|
||
|