Browse Source

handle large media

limit logging panel height
master
Silberengel 2 weeks ago
parent
commit
6b6f39e681
  1. 30
      src/imwald/core/author_html.py
  2. 3
      src/imwald/core/display_constants.py
  3. 91
      src/imwald/core/md_render.py
  4. 265
      src/imwald/core/media_compress.py
  5. 6
      src/imwald/core/nostr_engine.py
  6. 7
      src/imwald/ui/feed_page.py
  7. 50
      src/imwald/ui/main_window.py
  8. 9
      src/imwald/ui/profile_page.py
  9. 7
      src/imwald/ui/relay_status_panel.py
  10. 7
      src/imwald/ui/theme.py
  11. 7
      tests/test_md_render.py
  12. 68
      tests/test_media_compress.py

30
src/imwald/core/author_html.py

@ -25,10 +25,14 @@ def avatar_img_or_placeholder(
pic = safe_http_url(parsed.get("picture")) pic = safe_http_url(parsed.get("picture"))
r = max(6, size_px // 5) r = max(6, size_px // 5)
if pic: if pic:
return ( img = (
f'<img src="{pic}" width="{size_px}" height="{size_px}" alt="" ' f'<img src="{pic}" width="{size_px}" height="{size_px}" alt="" '
f'style="border-radius:{r}px;object-fit:cover;vertical-align:middle;flex-shrink:0">' f'style="border-radius:{r}px;object-fit:cover;vertical-align:middle;flex-shrink:0">'
) )
return (
f'<a class="imwald-fullimg" href="{pic}" title="View full size" '
f'style="text-decoration:none;display:inline-block">{img}</a>'
)
return ( return (
f'<span style="display:inline-block;width:{size_px}px;height:{size_px}px;' f'<span style="display:inline-block;width:{size_px}px;height:{size_px}px;'
f"border-radius:{r}px;background:{border_hex};" f"border-radius:{r}px;background:{border_hex};"
@ -59,16 +63,15 @@ def feed_op_author_block_html(
inner = ( inner = (
f'<div style="display:flex;align-items:flex-start;margin-bottom:12px">' f'<div style="display:flex;align-items:flex-start;margin-bottom:12px">'
f"{av}" f"{av}"
f'<div style="flex:1;min-width:0">' f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block;flex:1;min-width:0" '
f'title="View profile">'
f'<div style="font-size:21px;font-weight:600;color:{text}">{disp}</div>' f'<div style="font-size:21px;font-weight:600;color:{text}">{disp}</div>'
f'<div style="color:{muted};font-size:15px">{npub_e} · {pk_s}</div>' f'<div style="color:{muted};font-size:15px">{npub_e} · {pk_s}</div>'
f"{nip_line_html}{about_line_html}" f"{nip_line_html}{about_line_html}"
f"</div></div>" f"</a>"
) f"</div>"
return (
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block" '
f'title="View profile">{inner}</a>'
) )
return inner
def thread_reply_author_row_html( def thread_reply_author_row_html(
@ -91,16 +94,15 @@ def thread_reply_author_row_html(
inner = ( inner = (
'<div style="display:flex;align-items:center;gap:10px;margin:0 0 6px 0">' '<div style="display:flex;align-items:center;gap:10px;margin:0 0 6px 0">'
f"{av}" f"{av}"
'<div style="flex:1;min-width:0;line-height:1.35">' f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block;flex:1;min-width:0" '
f'title="View profile">'
'<div style="line-height:1.35">'
f'<span style="color:{dim};font-size:13px">k{int(kind)}</span> &nbsp; ' f'<span style="color:{dim};font-size:13px">k{int(kind)}</span> &nbsp; '
f'<b style="color:{text};font-size:15px">{name}</b> &nbsp; ' f'<b style="color:{text};font-size:15px">{name}</b> &nbsp; '
f'<span style="color:{muted};font-size:13px">{npub_e}</span>' f'<span style="color:{muted};font-size:13px">{npub_e}</span>'
"</div></div>" "</div></a></div>"
)
return (
f'<a href="{href}" style="text-decoration:none;color:inherit;cursor:pointer;display:block" '
f'title="View profile">{inner}</a>'
) )
return inner
def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str: def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str:
@ -111,8 +113,10 @@ def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, np
img = "" img = ""
if pic: if pic:
img = ( img = (
f'<a class="imwald-fullimg" href="{pic}" title="View full size" style="text-decoration:none">'
f'<img src="{pic}" width="20" height="20" alt="" ' f'<img src="{pic}" width="20" height="20" alt="" '
'style="border-radius:50%;object-fit:cover;margin-right:6px;vertical-align:middle;flex-shrink:0">' 'style="border-radius:50%;object-fit:cover;margin-right:6px;vertical-align:middle;flex-shrink:0">'
"</a>"
) )
inner = f"{img}<span style=\"font-weight:600\">@{name}</span>" inner = f"{img}<span style=\"font-weight:600\">@{name}</span>"
return f'<span class="nostr-user-badge" style="{badge_style}" title="{tip}">{inner}</span>' return f'<span class="nostr-user-badge" style="{badge_style}" title="{tip}">{inner}</span>'

3
src/imwald/core/display_constants.py

@ -0,0 +1,3 @@
"""Shared display limits for HTML fragments and Qt rich text."""
IMAGE_DISPLAY_MAX_WIDTH_PX = 400

91
src/imwald/core/md_render.py

@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, cast
import nh3 import nh3
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.nip30_emoji import preprocess_nip30_emoji_markdown, nip30_emoji_urls_from_tags from imwald.core.nip30_emoji import preprocess_nip30_emoji_markdown, nip30_emoji_urls_from_tags
from imwald.core.nostr_entity_render import preprocess_nostr_entities from imwald.core.nostr_entity_render import preprocess_nostr_entities
@ -62,6 +63,9 @@ _NH3_STYLE_FILTER = frozenset({
"gap", "gap",
"object-fit", "object-fit",
"vertical-align", "vertical-align",
"cursor",
"text-decoration",
"box-sizing",
}) })
@ -78,6 +82,11 @@ def _nh3_attributes() -> dict[str, set[str]]:
if img_a is not None: if img_a is not None:
img_a.add("style") img_a.add("style")
img_a.add("class") img_a.add("class")
a_a = raw.get("a")
if a_a is not None:
a_a.add("class")
a_a.add("title")
a_a.add("style")
_nh3_attrs_merged = dict(raw) _nh3_attrs_merged = dict(raw)
return _nh3_attrs_merged return _nh3_attrs_merged
@ -90,6 +99,65 @@ def _nh3_clean(html: str) -> str:
) )
_IMG_TAG_RE = re.compile(r"<img(\s[^>]*?)(?:/>|>)", re.IGNORECASE)
def _parse_img_width_height(attr_blob: str) -> tuple[int | None, int | None]:
"""Parse numeric ``width`` / ``height`` from an ``<img …>`` attribute blob (best-effort)."""
w_m = re.search(r"""\bwidth\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))\b""", attr_blob, re.I)
h_m = re.search(r"""\bheight\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))\b""", attr_blob, re.I)
w = None
h = None
if w_m:
w = int(next(g for g in w_m.groups() if g is not None))
if h_m:
h = int(next(g for g in h_m.groups() if g is not None))
return w, h
def _parse_img_src(attr_blob: str) -> str | None:
m = re.search(r"""\bsrc\s*=\s*("([^"]*)"|'([^']*)')""", attr_blob, re.I)
if not m:
return None
return m.group(2) or m.group(3) or None
def _wrap_http_images_for_fullsize(fragment: str) -> str:
"""
Wrap ``<img src="http(s):…">`` in ``<a href="">`` so QTextBrowser can open the image URL.
Skips tiny square thumbnails (both ``width`` and ``height`` 64) to avoid double-linking
small UI avatars that already sit inside other anchors.
"""
out: list[str] = []
last = 0
for m in _IMG_TAG_RE.finditer(fragment):
start, end = m.span()
attr_blob = m.group(1)
full_tag = fragment[start:end]
out.append(fragment[last:start])
last = end
src = _parse_img_src(attr_blob)
if not src or not src.startswith(("http://", "https://")):
out.append(full_tag)
continue
pre = fragment[max(0, start - 220) : start]
if re.search(r'<a\s[^>]*class="[^"\']*imwald-fullimg[^"\']*"[^>]*>\s*$', pre, re.I):
out.append(full_tag)
continue
w, h = _parse_img_width_height(attr_blob)
if w is not None and h is not None and w <= 64 and h <= 64:
out.append(full_tag)
continue
href = html.escape(src, quote=True)
out.append(
f'<a class="imwald-fullimg" href="{href}" title="View full size" '
f'style="text-decoration:none">{full_tag}</a>'
)
out.append(fragment[last:])
return "".join(out)
def _marked_quickjs_ctx() -> Context | None: def _marked_quickjs_ctx() -> Context | None:
"""Singleton QuickJS context with ``marked`` loaded, or None if unavailable.""" """Singleton QuickJS context with ``marked`` loaded, or None if unavailable."""
global _qjs_ctx, _marked_load_failed global _qjs_ctx, _marked_load_failed
@ -182,7 +250,8 @@ def markdown_html_fragment(
raw = _render_marked_js(md) raw = _render_marked_js(md)
if raw is None: if raw is None:
raw = _render_markdown_fallback(md) raw = _render_markdown_fallback(md)
return _nh3_clean(raw) cleaned = _nh3_clean(raw)
return _nh3_clean(_wrap_http_images_for_fullsize(cleaned))
def markdown_plain_summary( def markdown_plain_summary(
@ -231,15 +300,17 @@ def markdown_to_plain_text(
return plain.strip() return plain.strip()
_PREVIEW_CSS = """<style> _PREVIEW_W = IMAGE_DISPLAY_MAX_WIDTH_PX
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:17px;margin:0;padding:12px;line-height:1.45;color:#dceee6;} _PREVIEW_CSS = f"""<style>
pre,code{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:15px;} body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:17px;margin:0;padding:12px;line-height:1.45;color:#dceee6;}}
pre{background:#0a100d;color:#dceee6;padding:10px;border-radius:6px;overflow-x:auto;} pre,code{{font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:15px;}}
blockquote{border-left:3px solid #2a9d6f;margin:8px 0;padding:4px 0 4px 12px;color:#8fb0a3;} pre{{background:#0a100d;color:#dceee6;padding:10px;border-radius:6px;overflow-x:auto;}}
a{color:#5eead4;} blockquote{{border-left:3px solid #2a9d6f;margin:8px 0;padding:4px 0 4px 12px;color:#8fb0a3;}}
table{border-collapse:collapse;margin:8px 0;width:100%;} a{{color:#5eead4;}}
th,td{border:1px solid #2a3d34;padding:6px;} table{{border-collapse:collapse;margin:8px 0;width:100%;}}
img{max-width:min(100%,400px);height:auto;} th,td{{border:1px solid #2a3d34;padding:6px;}}
img{{max-width:{_PREVIEW_W}px;width:auto;height:auto;box-sizing:border-box;}}
a.imwald-fullimg{{color:inherit;text-decoration:none;}}
</style>""" </style>"""

265
src/imwald/core/media_compress.py

@ -0,0 +1,265 @@
"""Resize + compress images and (optionally) audio/video before upload or local storage."""
from __future__ import annotations
import io
import logging
import mimetypes
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
if TYPE_CHECKING:
from imwald.core.database import Database
log = logging.getLogger(__name__)
SETTING_MEDIA_COMPRESS_STRENGTH = "media_compress_strength" # "0" light, "1" balanced, "2" strong
SETTING_MEDIA_MAX_IMAGE_WIDTH = "media_max_image_width" # pixels, default 1000
MEDIA_STORE_MAX_WIDTH_DEFAULT = 1000
MediaStrength = Literal[0, 1, 2]
def read_compression_strength(db: Database | None) -> MediaStrength:
"""0 = light (larger files), 1 = balanced, 2 = strong (smaller files)."""
if db is None:
return 1
raw = (db.get_setting(SETTING_MEDIA_COMPRESS_STRENGTH, "1") or "1").strip()
try:
v = int(raw)
except ValueError:
v = 1
v = max(0, min(2, v))
return cast(MediaStrength, v)
def read_max_image_width(db: Database | None) -> int:
if db is None:
return MEDIA_STORE_MAX_WIDTH_DEFAULT
raw = (db.get_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, str(MEDIA_STORE_MAX_WIDTH_DEFAULT)) or "").strip()
try:
w = int(raw)
except ValueError:
return MEDIA_STORE_MAX_WIDTH_DEFAULT
return max(320, min(w, 8192))
def _strength_params(level: MediaStrength) -> dict[str, int | str]:
if level == 0:
return {"jpeg": 90, "png": 6, "video_crf": 20, "audio_abr": "128k"}
if level == 1:
return {"jpeg": 78, "png": 9, "video_crf": 23, "audio_abr": "96k"}
return {"jpeg": 66, "png": 9, "video_crf": 28, "audio_abr": "64k"}
def _guess_kind(filename: str, data: bytes) -> Literal["image", "video", "audio", "unknown"]:
ext = Path(filename).suffix.lower()
audio_ext = frozenset({".mp3", ".m4a", ".aac", ".opus", ".ogg", ".oga", ".flac", ".wav"})
video_ext = frozenset({".mp4", ".mkv", ".mov", ".avi", ".m4v"})
if ext in audio_ext:
return "audio"
if ext == ".webm":
if data.startswith(b"\x1a\x45\xdf\xa3"):
return "video"
return "audio"
if ext in video_ext:
return "video"
if ext in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".avif"}:
return "image"
if data[:8] == b"\x89PNG\r\n\x1a\n" or data[:2] == b"\xff\xd8" or data[:6] in (b"GIF87a", b"GIF89a"):
return "image"
guessed, _ = mimetypes.guess_type(filename)
if guessed:
if guessed.startswith("image/"):
return "image"
if guessed.startswith("video/"):
return "video"
if guessed.startswith("audio/"):
return "audio"
return "unknown"
def _ffmpeg_available() -> bool:
return shutil.which("ffmpeg") is not None
def compress_image_bytes(
data: bytes,
*,
max_width: int,
strength: MediaStrength,
original_name: str,
) -> tuple[bytes, str, str]:
"""
Downscale to ``max_width`` on the wide edge (if wider), then compress.
Returns ``(bytes, mime_type, filename)``.
"""
from PIL import Image, ImageOps # type: ignore[import-not-found, import-untyped]
p = _strength_params(strength)
jpeg_q = int(p["jpeg"])
png_level = int(p["png"])
bio = io.BytesIO(data)
im_t = Image.open(bio)
im_t = ImageOps.exif_transpose(im_t)
im_t.load()
w0, _h0 = im_t.size
if w0 > max_width:
nh = max(1, int(round(im_t.size[1] * (max_width / float(w0)))))
im_t = im_t.resize((max_width, nh), Image.Resampling.LANCZOS) # pyright: ignore[reportUnknownMemberType]
im = im_t
has_alpha = im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info)
stem = Path(original_name).stem or "image"
if has_alpha:
out = im.convert("RGBA")
buf = io.BytesIO()
out.save(buf, format="PNG", compress_level=png_level, optimize=True)
blob = buf.getvalue()
return blob, "image/png", f"{stem}.png"
rgb = im.convert("RGB")
buf = io.BytesIO()
rgb.save(buf, format="JPEG", quality=jpeg_q, optimize=True, progressive=True)
blob = buf.getvalue()
return blob, "image/jpeg", f"{stem}.jpg"
def _compress_video_ffmpeg(
src: Path,
dst: Path,
*,
max_width: int,
strength: MediaStrength,
) -> bool:
p = _strength_params(strength)
crf = int(p["video_crf"])
abr = str(p["audio_abr"])
vf = f"scale=min({max_width}\\,iw):-2"
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-vf",
vf,
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
str(crf),
"-c:a",
"aac",
"-b:a",
abr,
"-movflags",
"+faststart",
str(dst),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as e:
log.warning("ffmpeg video compress failed: %s", e)
return False
return dst.is_file() and dst.stat().st_size > 0
def _compress_audio_ffmpeg(src: Path, out_m4a: Path, *, strength: MediaStrength) -> bool:
p = _strength_params(strength)
abr = str(p["audio_abr"])
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-vn",
"-c:a",
"aac",
"-b:a",
abr,
str(out_m4a),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as e:
log.warning("ffmpeg audio compress failed: %s", e)
return False
return out_m4a.is_file() and out_m4a.stat().st_size > 0
def prepare_bytes_for_storage(
data: bytes,
filename: str,
*,
mime_hint: str | None,
db: Database | None,
strength: MediaStrength | None = None,
) -> tuple[bytes, str, str]:
"""
Apply user-configured compression before upload or writing ``media_cache``.
Returns ``(blob, mime_type, filename)``. On failure, returns the original bytes.
"""
if not data:
return data, mime_hint or "application/octet-stream", filename
lvl: MediaStrength = strength if strength is not None else read_compression_strength(db)
max_w = read_max_image_width(db)
kind = _guess_kind(filename, data)
mime = mime_hint or mimetypes.guess_type(filename)[0] or "application/octet-stream"
if kind == "image":
try:
out_b, out_m, out_n = compress_image_bytes(
data, max_width=max_w, strength=lvl, original_name=filename
)
return out_b, out_m, out_n
except Exception as e: # noqa: BLE001
log.warning("image compress skipped: %s", e)
return data, mime, filename
if kind in ("video", "audio") and not _ffmpeg_available():
log.info("ffmpeg not found; skipping %s compression", kind)
return data, mime, filename
if kind == "video" and _ffmpeg_available():
suf = Path(filename).suffix.lower() or ".mp4"
with tempfile.TemporaryDirectory() as td:
td_path = Path(td)
src = td_path / f"in{suf}"
dst = td_path / "out.mp4"
src.write_bytes(data)
if _compress_video_ffmpeg(src, dst, max_width=max_w, strength=lvl):
out_b = dst.read_bytes()
stem = Path(filename).stem or "video"
if len(out_b) < len(data):
return out_b, "video/mp4", f"{stem}.mp4"
return data, mime, filename
if kind == "audio" and _ffmpeg_available():
suf = Path(filename).suffix.lower() or ".m4a"
with tempfile.TemporaryDirectory() as td:
td_path = Path(td)
src = td_path / f"in{suf}"
dst = td_path / "out.m4a"
src.write_bytes(data)
if _compress_audio_ffmpeg(src, dst, strength=lvl):
out_b = dst.read_bytes()
stem = Path(filename).stem or "audio"
if len(out_b) < len(data):
return out_b, "audio/mp4", f"{stem}.m4a"
return data, mime, filename
return data, mime, filename

6
src/imwald/core/nostr_engine.py

@ -282,10 +282,14 @@ class NostrEngine(QObject):
tags0: list[list[str]] = [["client", "imwald"]] tags0: list[list[str]] = [["client", "imwald"]]
try: try:
from imwald.core.forest_avatar import build_forest_avatar_png from imwald.core.forest_avatar import build_forest_avatar_png
from imwald.core.media_compress import prepare_bytes_for_storage
from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta, upload_image_nip96_nostr_build from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta, upload_image_nip96_nostr_build
png = build_forest_avatar_png() png = build_forest_avatar_png()
url, nip94_tags = upload_image_nip96_nostr_build(sec, png) blob, mime, fname = prepare_bytes_for_storage(
png, "imwald-forest-avatar.png", mime_hint="image/png", db=self.db
)
url, nip94_tags = upload_image_nip96_nostr_build(sec, blob, filename=fname, mime=mime)
profile["picture"] = url profile["picture"] = url
tags0.append(nip94_tags_to_imeta(nip94_tags)) tags0.append(nip94_tags_to_imeta(nip94_tags))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001

7
src/imwald/ui/feed_page.py

@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.kind0_profile import parse_kind0_profile
from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text
from imwald.core.nip19 import encode_npub from imwald.core.nip19 import encode_npub
@ -110,9 +111,12 @@ def _format_engagement_html(
if u: if u:
esc_u = html.escape(u, quote=True) esc_u = html.escape(u, quote=True)
esc_alt = html.escape(raw_disp, quote=True) esc_alt = html.escape(raw_disp, quote=True)
mx = IMAGE_DISPLAY_MAX_WIDTH_PX
e = ( e = (
f'<a href="{esc_u}">'
f'<img src="{esc_u}" alt="{esc_alt}" width="20" height="20" ' f'<img src="{esc_u}" alt="{esc_alt}" width="20" height="20" '
'style="vertical-align:middle;max-height:1.35em;width:auto" />' f'style="vertical-align:middle;max-height:1.35em;max-width:{mx}px;width:auto" />'
f"</a>"
) )
else: else:
e = html.escape(raw_disp, quote=False) e = html.escape(raw_disp, quote=False)
@ -161,6 +165,7 @@ class FeedPage(QWidget):
eng_layout.setContentsMargins(0, 0, 0, 0) eng_layout.setContentsMargins(0, 0, 0, 0)
self._engagement_label = QLabel("") self._engagement_label = QLabel("")
self._engagement_label.setTextFormat(Qt.TextFormat.RichText) self._engagement_label.setTextFormat(Qt.TextFormat.RichText)
self._engagement_label.setOpenExternalLinks(True)
self._engagement_label.setWordWrap(True) self._engagement_label.setWordWrap(True)
eng_layout.addWidget(self._engagement_label) eng_layout.addWidget(self._engagement_label)
self._engagement_label.installEventFilter(self) self._engagement_label.installEventFilter(self)

50
src/imwald/ui/main_window.py

@ -9,6 +9,8 @@ from PySide6.QtGui import QAction, QCloseEvent, QColor, QIcon, QPainter, QPen, Q
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox,
QFormLayout,
QInputDialog, QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
@ -16,6 +18,7 @@ from PySide6.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QSpinBox,
QSplitter, QSplitter,
QStackedWidget, QStackedWidget,
QTabWidget, QTabWidget,
@ -29,6 +32,13 @@ from imwald.core.database import Database
from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.kind0_profile import parse_kind0_profile
from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine
from imwald.core.md_render import markdown_plain_summary from imwald.core.md_render import markdown_plain_summary
from imwald.core.media_compress import (
MEDIA_STORE_MAX_WIDTH_DEFAULT,
SETTING_MEDIA_COMPRESS_STRENGTH,
SETTING_MEDIA_MAX_IMAGE_WIDTH,
read_compression_strength,
read_max_image_width,
)
from imwald.core.relay_list import resolve_for_account from imwald.core.relay_list import resolve_for_account
from imwald.core.relay_policy import augment_feed_with_trending from imwald.core.relay_policy import augment_feed_with_trending
from imwald.ui.composer_dialog import ComposerDialog from imwald.ui.composer_dialog import ComposerDialog
@ -329,6 +339,9 @@ class MainWindow(QMainWindow):
a_db = QAction("&Local database…", self) a_db = QAction("&Local database…", self)
a_db.triggered.connect(lambda: self._go_stack_page(3)) a_db.triggered.connect(lambda: self._go_stack_page(3))
m_tools.addAction(a_db) m_tools.addAction(a_db)
a_media = QAction("&Media compression…", self)
a_media.triggered.connect(self._open_media_compression_dialog)
m_tools.addAction(a_media)
m_help = self.menuBar().addMenu("&Help") m_help = self.menuBar().addMenu("&Help")
a_about = QAction("&About", self) a_about = QAction("&About", self)
@ -341,6 +354,43 @@ class MainWindow(QMainWindow):
) )
m_help.addAction(a_about) m_help.addAction(a_about)
def _open_media_compression_dialog(self) -> None:
dlg = QDialog(self)
dlg.setWindowTitle("Media compression")
lay = QVBoxLayout(dlg)
form = QFormLayout()
combo = QComboBox()
combo.addItem("Light — larger files, higher quality", 0)
combo.addItem("Balanced — default", 1)
combo.addItem("Strong — smaller files, more loss", 2)
cur = read_compression_strength(self._db)
idx = combo.findData(int(cur))
combo.setCurrentIndex(max(0, idx))
spin = QSpinBox()
spin.setRange(320, 8192)
spin.setSingleStep(40)
spin.setValue(read_max_image_width(self._db))
spin.setToolTip("Images are scaled down to this width (if wider) before JPEG/PNG compression.")
form.addRow("Strength (images + ffmpeg A/V)", combo)
form.addRow("Max image / video scale width (px)", spin)
lay.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
lay.addWidget(buttons)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
self._db.set_setting(SETTING_MEDIA_COMPRESS_STRENGTH, str(int(combo.currentData())))
self._db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, str(int(spin.value())))
QMessageBox.information(
self,
"Media compression",
"Settings saved. They apply to the next upload (e.g. new profile picture via onboarding). "
f"Default max width is {MEDIA_STORE_MAX_WIDTH_DEFAULT}px if you reset the database.",
)
def _wire_engine(self) -> None: def _wire_engine(self) -> None:
self._engine.event_ingested.connect(self._on_event_ingested) self._engine.event_ingested.connect(self._on_event_ingested)
self._engine.relay_status.connect(self._relay_status_message) self._engine.relay_status.connect(self._relay_status_message)

9
src/imwald/ui/profile_page.py

@ -11,6 +11,7 @@ from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget
from imwald.core.author_html import avatar_img_or_placeholder from imwald.core.author_html import avatar_img_or_placeholder
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
from imwald.core.database import Database from imwald.core.database import Database
from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile
from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls
@ -145,9 +146,13 @@ class ProfilePage(QWidget):
banner_html = "" banner_html = ""
if banner and str(banner).strip().startswith("https://"): if banner and str(banner).strip().startswith("https://"):
bu = html.escape(str(banner).strip(), quote=True) bu = html.escape(str(banner).strip(), quote=True)
bw = IMAGE_DISPLAY_MAX_WIDTH_PX
banner_html = ( banner_html = (
f"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden'>" f"<div style='margin-bottom:12px;border-radius:10px;overflow:hidden;"
f'<img src="{bu}" alt="" style="width:100%;max-height:160px;object-fit:cover" /></div>' f"max-width:{bw}px'>"
f'<a class="imwald-fullimg" href="{bu}" title="View full size" style="text-decoration:none">'
f'<img src="{bu}" alt="" style="max-width:100%;width:100%;max-height:160px;object-fit:cover" />'
f"</a></div>"
) )
about_raw = (parsed.get("about") or "").strip() about_raw = (parsed.get("about") or "").strip()

7
src/imwald/ui/relay_status_panel.py

@ -173,7 +173,7 @@ class RelayStatusPanel(QWidget):
relay_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) relay_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
relay_scroll.setFrameShape(QFrame.Shape.NoFrame) relay_scroll.setFrameShape(QFrame.Shape.NoFrame)
relay_scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }}") relay_scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }}")
relay_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) relay_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
self._log = QTextEdit() self._log = QTextEdit()
self._log.setReadOnly(True) self._log.setReadOnly(True)
@ -192,9 +192,10 @@ class RelayStatusPanel(QWidget):
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
split.addWidget(relay_scroll) split.addWidget(relay_scroll)
split.addWidget(self._log) split.addWidget(self._log)
split.setStretchFactor(0, 0) # Relays ~2/3 height, log ~1/3 when the panel is resized.
split.setStretchFactor(0, 2)
split.setStretchFactor(1, 1) split.setStretchFactor(1, 1)
split.setSizes([200, 360]) split.setSizes([400, 200])
root.addWidget(split, stretch=1) root.addWidget(split, stretch=1)
self.setMaximumWidth(360) self.setMaximumWidth(360)

7
src/imwald/ui/theme.py

@ -4,6 +4,8 @@ from __future__ import annotations
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX
# Shared with HTML fragments (feed header, markdown preview). # Shared with HTML fragments (feed header, markdown preview).
TEXT = "#dceee6" TEXT = "#dceee6"
TEXT_MUTED = "#8fb0a3" TEXT_MUTED = "#8fb0a3"
@ -17,6 +19,7 @@ BG_CARD = "#151f1a"
BORDER = "#2a3d34" BORDER = "#2a3d34"
BG_CODE = "#0a100d" BG_CODE = "#0a100d"
_W = IMAGE_DISPLAY_MAX_WIDTH_PX
FEED_DOC_CSS = f""" FEED_DOC_CSS = f"""
<style> <style>
body {{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px; body {{ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px;
@ -25,7 +28,9 @@ a {{ color: {LINK}; }}
pre, code {{ font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 15px; }} pre, code {{ font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 15px; }}
pre {{ background: {BG_CODE}; padding: 10px; border-radius: 8px; overflow-x: auto; color: {TEXT}; }} pre {{ background: {BG_CODE}; padding: 10px; border-radius: 8px; overflow-x: auto; color: {TEXT}; }}
blockquote {{ border-left: 3px solid {ACCENT_SOFT}; margin: 8px 0; padding: 4px 0 4px 12px; color: {TEXT_MUTED}; }} blockquote {{ border-left: 3px solid {ACCENT_SOFT}; margin: 8px 0; padding: 4px 0 4px 12px; color: {TEXT_MUTED}; }}
.md img {{ max-width: min(100%, 400px); height: auto; border-radius: 8px; margin: 6px 0; }} /* QTextDocument does not support CSS min(); use a plain max-width or images collapse. */
img {{ max-width: {_W}px; width: auto; height: auto; border-radius: 8px; margin: 6px 0; box-sizing: border-box; }}
a.imwald-fullimg {{ color: inherit; text-decoration: none; }}
.md p {{ margin: 0.45em 0; }} .md p {{ margin: 0.45em 0; }}
</style> </style>
""" """

7
tests/test_md_render.py

@ -21,3 +21,10 @@ def test_preprocess_turns_bare_image_url_into_markdown_image() -> None:
md = "hello\nhttps://example.com/x.png\nbye" md = "hello\nhttps://example.com/x.png\nbye"
out = preprocess_standalone_image_urls(md) out = preprocess_standalone_image_urls(md)
assert "![](https://example.com/x.png)" in out assert "![](https://example.com/x.png)" in out
def test_markdown_image_wrapped_for_fullsize_link() -> None:
html = markdown_html_fragment("![](https://example.com/wide.png)")
assert "imwald-fullimg" in html
assert "https://example.com/wide.png" in html
assert "<img" in html

68
tests/test_media_compress.py

@ -0,0 +1,68 @@
"""Media compression before storage / upload (Pillow images; ffmpeg optional for A/V)."""
from __future__ import annotations
import io
import tempfile
from pathlib import Path
from PIL import Image
from imwald.core.database import Database
from imwald.core.media_compress import (
SETTING_MEDIA_COMPRESS_STRENGTH,
SETTING_MEDIA_MAX_IMAGE_WIDTH,
compress_image_bytes,
prepare_bytes_for_storage,
read_compression_strength,
read_max_image_width,
)
def _rgb_wide_png() -> bytes:
im = Image.new("RGB", (1600, 400), color=(200, 30, 30))
buf = io.BytesIO()
im.save(buf, format="PNG", compress_level=3)
return buf.getvalue()
def test_compress_image_scales_to_max_width() -> None:
raw = _rgb_wide_png()
out, mime, name = compress_image_bytes(raw, max_width=1000, strength=1, original_name="x.png")
assert mime == "image/jpeg"
assert name.endswith(".jpg")
im = Image.open(io.BytesIO(out))
assert im.size[0] <= 1000
def test_strength_stronger_yields_smaller_or_equal_jpeg() -> None:
raw = _rgb_wide_png()
light, _, _ = compress_image_bytes(raw, max_width=1000, strength=0, original_name="w.png")
strong, _, _ = compress_image_bytes(raw, max_width=1000, strength=2, original_name="w.png")
assert len(strong) <= len(light)
def test_read_settings_from_db() -> None:
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "m.sqlite")
db.connect()
assert read_compression_strength(db) == 1
assert read_max_image_width(db) == 1000
db.set_setting(SETTING_MEDIA_COMPRESS_STRENGTH, "2")
db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, "1200")
assert read_compression_strength(db) == 2
assert read_max_image_width(db) == 1200
def test_prepare_bytes_image_round_trip() -> None:
raw = _rgb_wide_png()
with tempfile.TemporaryDirectory() as td:
db = Database(Path(td) / "m2.sqlite")
db.connect()
db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, "800")
out, mime, _name = prepare_bytes_for_storage(
raw, "wide.png", mime_hint="image/png", db=db, strength=1
)
assert mime == "image/jpeg"
im = Image.open(io.BytesIO(out))
assert im.size[0] <= 800
Loading…
Cancel
Save