-
Notifications
You must be signed in to change notification settings - Fork 2
feat: download RAW/HEIC images as JPEG or PNG #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import io | ||
| import os | ||
| import tempfile | ||
|
|
||
| import frappe | ||
| import requests | ||
| from frappe import _ | ||
| from PIL import Image, ImageOps | ||
|
|
||
| from vms.r2 import generate_presigned_view_url | ||
| from vms.raw_images import is_raw, open_raw_preview | ||
|
|
||
| HEIC_EXTENSIONS = frozenset({".heic", ".heif"}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The conversion code supports HEIC and HEIF, but the default upload extension allowlist still omits Knowledge Base Used: Platform APIs and operations Prompt To Fix With AIThis is a comment left during a code review.
Path: vms/image_export.py
Line: 13
Comment:
**HEIC uploads remain blocked**
The conversion code supports HEIC and HEIF, but the default upload extension allowlist still omits `heic` and `heif`. On normally configured sites, the upload API rejects these files before they can reach the new conversion feature. Add both extensions to the default and provide an upgrade path for existing `VMS Settings` records.
**Knowledge Base Used:** [Platform APIs and operations](https://app.greptile.com/bwh-tech/-/custom-context/knowledge-base/bwhtech/vms/-/docs/platform-api-and-operations.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in dc1a55f — added |
||
| HEIC_MIMES = frozenset({"image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"}) | ||
|
|
||
| TARGET_FORMATS = { | ||
| "jpeg": ("JPEG", "image/jpeg", "jpg"), | ||
| "jpg": ("JPEG", "image/jpeg", "jpg"), | ||
| "png": ("PNG", "image/png", "png"), | ||
| } | ||
|
|
||
| JPEG_QUALITY = 90 | ||
| _WHITE = (255, 255, 255) | ||
|
|
||
| MAX_SOURCE_BYTES = 300 * 1024 * 1024 | ||
|
|
||
|
|
||
| def _extension(file_name: str | None) -> str: | ||
| if not file_name: | ||
| return "" | ||
| return os.path.splitext(file_name)[1].lower() | ||
|
|
||
|
|
||
| def is_heic(file_type: str | None = None, file_name: str | None = None) -> bool: | ||
| if file_type in HEIC_MIMES: | ||
| return True | ||
| return _extension(file_name) in HEIC_EXTENSIONS | ||
|
|
||
|
|
||
| def is_convertible_still(file_type: str | None = None, file_name: str | None = None) -> bool: | ||
| return is_raw(file_type, file_name) or is_heic(file_type, file_name) | ||
|
|
||
|
|
||
| def converted_file_name(file_name: str, ext: str) -> str: | ||
| base = os.path.splitext(file_name)[0] or file_name | ||
| return f"{base}.{ext}" | ||
|
|
||
|
|
||
| def _register_heif(): | ||
| try: | ||
| from pillow_heif import register_heif_opener | ||
| except ImportError: | ||
| frappe.throw(_("HEIC support is not installed on the server (pillow-heif).")) | ||
| register_heif_opener() | ||
|
|
||
|
|
||
| def _render(src_path: str, file_type: str | None, file_name: str | None) -> Image.Image: | ||
| if is_raw(file_type, file_name): | ||
| return open_raw_preview(src_path) | ||
|
|
||
| if is_heic(file_type, file_name): | ||
| _register_heif() | ||
|
|
||
| img = Image.open(src_path) | ||
| img.load() | ||
| return ImageOps.exif_transpose(img) | ||
|
|
||
|
|
||
| def _has_alpha(img: Image.Image) -> bool: | ||
| return img.mode in ("RGBA", "LA", "PA") or "transparency" in img.info | ||
|
|
||
|
|
||
| def _encode(img: Image.Image, pil_format: str) -> bytes: | ||
| buf = io.BytesIO() | ||
| if pil_format == "JPEG": | ||
| if _has_alpha(img): | ||
| rgba = img.convert("RGBA") | ||
| flattened = Image.new("RGB", rgba.size, _WHITE) | ||
| flattened.paste(rgba, mask=rgba.split()[-1]) | ||
| img = flattened | ||
| else: | ||
| img = img.convert("RGB") | ||
| img.save(buf, "JPEG", quality=JPEG_QUALITY, optimize=True, progressive=True) | ||
| else: | ||
| img = img.convert("RGBA") if _has_alpha(img) else img.convert("RGB") | ||
| img.save(buf, "PNG", optimize=True) | ||
| return buf.getvalue() | ||
|
|
||
|
|
||
| def _download_source(r2_key: str, dest_path: str): | ||
| resp = requests.get(generate_presigned_view_url(r2_key), stream=True, timeout=120) | ||
| resp.raise_for_status() | ||
| # nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal | ||
| with open(dest_path, "wb") as f: | ||
| for chunk in resp.iter_content(chunk_size=1024 * 1024): | ||
| f.write(chunk) | ||
|
|
||
|
|
||
| def convert_asset_image(asset, target_format: str) -> tuple[bytes, str, str]: | ||
| fmt = TARGET_FORMATS.get((target_format or "").lower()) | ||
| if not fmt: | ||
| frappe.throw(_("Unsupported download format.")) | ||
| pil_format, mime, ext = fmt | ||
|
|
||
| if not is_convertible_still(asset.file_type, asset.file_name): | ||
| frappe.throw(_("This file is not available as JPEG or PNG.")) | ||
|
|
||
| if asset.file_size and asset.file_size > MAX_SOURCE_BYTES: | ||
| frappe.throw(_("This file is too large to convert on download. Download the original instead.")) | ||
|
|
||
| with tempfile.TemporaryDirectory(prefix="vms_export_") as tmp: | ||
| src_path = os.path.join(tmp, f"source{_extension(asset.file_name) or '.bin'}") | ||
| _download_source(asset.r2_key, src_path) | ||
| img = _render(src_path, asset.file_type, asset.file_name) | ||
| data = _encode(img, pil_format) | ||
|
|
||
| return data, mime, converted_file_name(asset.file_name, ext) | ||
|
|
||
|
|
||
| def serve_converted_download(asset, target_format: str): | ||
| data, mime, out_name = convert_asset_image(asset, target_format) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| from vms.deletion import _create_audit_log | ||
|
|
||
| _create_audit_log( | ||
| action="Download", | ||
| asset_name=asset.name, | ||
| file_name=out_name, | ||
| file_type=mime, | ||
| project=asset.project, | ||
| file_size=len(data), | ||
| ) | ||
|
|
||
| frappe.local.response.filename = out_name | ||
| frappe.local.response.filecontent = data | ||
| frappe.local.response.content_type = mime | ||
| frappe.local.response.type = "download" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When conversion fails because the token expired, the asset is oversized, R2 is unavailable, or decoding fails, this anchor-based request cannot inspect the HTTP response or show an error toast. The browser may download the Frappe error response under the requested
.jpgor.pngfilename after telling the user the conversion is being prepared, leaving them with a corrupt-looking file and no actionable feedback.Knowledge Base Used:
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!