commit c93344739adf5656dc4b97522c033eb6074e5237 Author: Djeex Date: Thu Jul 30 22:31:34 2026 +0200 1st commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17b0f90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Runtime job files — uploaded/generated images, never committed +jobs/* +!jobs/.gitkeep + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# OS clutter +.DS_Store + +# Personal test images, never committed +test/ + +# Local editor/tool config +.claude/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2085d89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,33 @@ +MIT License + +Copyright (c) 2026 Kostis K. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +NOTICE + +This repository's gain-map assembly pipeline (app/assembler.py) is adapted +from kostis-kounadis/instagram-hdr-assembler: +https://github.com/kostis-kounadis/instagram-hdr-assembler + +That project is itself based on the reverse-engineering work of +karachungen/instagram-hdr-converter: +https://github.com/karachungen/instagram-hdr-converter diff --git a/README.md b/README.md new file mode 100644 index 0000000..8199271 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +> [!NOTE] +> ⚠️ _This is an heavily vibe-coded proof of concept — **do not expose it to the internet** — use it at your own risk._ +> _Github repo is a mirror of https://git.djeex.fr/Djeex/insta-hdr-converter. You'll find full package, history and release note there._ + +# Instagram HDR Assembler + +A small web front end for assembling Instagram-compatible HDR JPEGs (gain +map) from an SDR export and an HDR export out of Lightroom or Camera Raw. + +The gain-map assembly logic ([app/assembler.py](app/assembler.py)) is +adapted from [kostis-kounadis/instagram-hdr-assembler](https://github.com/kostis-kounadis/instagram-hdr-assembler), +itself based on the reverse-engineering work of +[karachungen/instagram-hdr-converter](https://github.com/karachungen/instagram-hdr-converter). +Original MIT license kept in [LICENSE](LICENSE). + +Only the upstream's Method 2 (starting from an HDR JPEG that already +carries a gain map) is implemented here. Method 1's AVIF decoding and +its quality/transfer/gamut settings were dropped, but the HDR side can +still be a 32-bit float linear TIFF (Lightroom's HDR TIFF export) +instead of a gain-map JPEG for a more precise result. Either way the +gain map is recomputed rather than reused as-is (see below), since it +needs to be correct against the SDR file you actually provide. + +![Instagram HDR Assembler screenshot](screenshots/insta-hdr.png) + +## How it works + +1. Upload an SDR file (`.jpg`/`.jpeg`) and an HDR file: either a gain-map + JPEG (`.jpg`/`.jpeg`) or Lightroom's 32-bit float linear HDR TIFF + export (`.tif`/`.tiff`). +2. Both files must have identical dimensions and match one of Instagram's + feed resolutions exactly: `1080x1080` (1:1), `1080x1350` (4:5), or + `1080x566` (1.91:1). +3. The real HDR pixel data is obtained from the HDR file: decoded back + out of a gain-map JPEG (`ultrahdr_app`, undoing its own embedded base + image and gain map), or read directly from a linear TIFF and given + HLG's system-gamma OOTF so it's on the same display-referred scale a + gain-map JPEG's HDR intent would be. A gain map is only correct + against the exact base it was computed from, and that's rarely the + SDR file you upload, so reusing an existing one as-is would quietly + reconstruct the wrong HDR look. Your SDR file is cleaned up and + re-encoded at 4:2:0 chroma subsampling (Pillow), then a fresh gain map + is computed against it and packaged into the final UltraHDR JPEG + (`ultrahdr_app`). The output's visible image is exactly your SDR file; + only the gain map is recomputed. The TIFF route skips the gain-map + JPEG's 8-bit-in-8-bit precision ceiling, giving a more accurate result. +4. The validation report is shown on the result page, including the + "GainMapMin is negative" warning when present, with a download link + for the finished JPEG. + +Uploads and results don't stick around: SDR/HDR originals are deleted +right after conversion, the result a few seconds after you leave the +result page (heartbeat + background reaper), and `jobs/` is wiped on every +app startup. + + +## Running locally, without Docker + +```bash +pip install -r requirements.txt +# also install exiftool and compile libultrahdr, see docker/Dockerfile +# for the exact packages and commands (validated on Alpine) +PORT=5050 python3 main.py # PORT is optional, defaults to 5000 +``` + +## Running with Docker + +```bash +docker compose -f docker/compose.yaml up -d --build +``` + +Opens on `http://:5050` (port 5000 collides with AirPlay Receiver on +macOS, hence 5050 by default; change it in `docker/compose.yaml` if +needed). + +## Running this on the open internet + +Don't do it. \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..afaf360 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..121c9ee --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,26 @@ +"""HDR-to-Instagram web front — Flask app factory.""" +from flask import Flask + +from . import jobs +from .config import BASE_DIR, MAX_CONTENT_LENGTH, VERSION +from .routes import register_routes + + +def create_app() -> Flask: + app = Flask( + __name__, + template_folder=str(BASE_DIR / "templates"), + static_folder=str(BASE_DIR / "static"), + ) + app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH + + jobs.reset_jobs_dir() + jobs.start_reaper() + + register_routes(app) + + @app.context_processor + def inject_version(): + return {"app_version": VERSION} + + return app diff --git a/app/assembler.py b/app/assembler.py new file mode 100644 index 0000000..d316cd1 --- /dev/null +++ b/app/assembler.py @@ -0,0 +1,368 @@ +"""Assembles the final Instagram HDR JPEG from an SDR export + an HDR +JPEG that already carries a gain map (a Lightroom/Camera Raw HDR export). + +Adapted from kostis-kounadis/instagram-hdr-assembler (MIT-licensed, see +LICENSE at the repo root). The upstream script also supported a Method 1 +pipeline (decoding AVIF/TIFF into a dynamically generated gain map), but +app/validation.py only ever accepts a .jpg HDR export with an existing +gain map, so that path, and its quality/transfer/gamut knobs, was +dropped here. + +A gain map only reconstructs a correct HDR image when it's applied to the +exact SDR base it was computed against. Lightroom's own HDR export bakes +its gain map against *its own* embedded base image, which is very often +not pixel-identical to a separately-exported SDR file, even from the +same edit (different sharpening, noise reduction, or color-space +rounding between export passes), and definitely not identical when the +SDR is a deliberately different edit. Reusing that gain map as-is against +a different SDR (the old approach) silently reconstructs the wrong HDR +appearance. Instead, this pipeline decodes the real HDR intent out of the +Lightroom file (undoing its own base and gain map), then recomputes a +fresh gain map against the user's actual SDR file, so the output's +visible base is exactly the provided SDR, and the gain map is the +correct one for reconstructing the original HDR intent from it. + +The HDR side can optionally be a 32-bit float linear TIFF instead of a +gain-map JPEG (Lightroom's HDR TIFF export, 1.0 = SDR reference white). +This skips the JPEG gain-map JPEG entirely, so there's no 8-bit-in-8-bit +precision ceiling and no JPEG-compression noise inflating near-black +pixels into meaningless outlier ratios; the real per-pixel HDR/SDR ratio +is used directly. The SDR side stays a normal JPEG export either way, +matching how Lightroom's own HDR export plugins do it (SDR JPEG as base, +a separate TIFF only for the HDR intent) and avoiding having to +re-derive a viewable, correctly color-managed JPEG from linear TIFF +data ourselves. +""" +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import numpy as np +import tifffile +from PIL import Image + +from .config import CONVERT_TIMEOUT, TIFF_EXT + +REQUIRED_TOOLS = ("ultrahdr_app", "exiftool") + + +class AssemblyError(Exception): + """A pipeline step failed; the message is shown on the result page.""" + + +def check_dependencies() -> list[str]: + """Returns the names of any required external tool missing from PATH.""" + return [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)] + + +def _decode_hdr_intent(hdr_path: Path, dest: Path) -> None: + """Decodes the real (linear) HDR pixel data out of a gain-map JPEG, + undoing its own embedded base + gain map.""" + ultrahdr_app = shutil.which("ultrahdr_app") + result = subprocess.run( + [ultrahdr_app, "-m", "1", "-j", str(hdr_path), "-z", str(dest)], + capture_output=True, text=True, timeout=CONVERT_TIMEOUT, + ) + if result.returncode != 0: + raise AssemblyError( + "Could not decode HDR intent from the provided file " + f"(not a valid HDR JPEG with an embedded gain map?): {result.stderr.strip()}" + ) + + +def _detect_gamut(path: Path) -> str: + """Best-effort mapping from the embedded ICC profile to ultrahdr_app's + gamut codes. ultrahdr_app cross-checks the -c/-C value it's given + against the ICC box for compressed JPEG inputs and errors out on a + mismatch, so a wide-gamut export (Display P3, Rec.2020) needs the + right code passed explicitly, defaults to bt709/sRGB otherwise.""" + result = subprocess.run( + ["exiftool", "-ICC_Profile:ProfileDescription", "-s3", str(path)], + capture_output=True, text=True, timeout=CONVERT_TIMEOUT, + ) + description = result.stdout.strip().lower() + if "2020" in description or "2100" in description: + return "2" # bt2100 + if "p3" in description: + return "1" # p3 + return "0" # bt709 / sRGB + + +def _strip_xmp(path: Path) -> None: + subprocess.run( + ["exiftool", "-xmp:all=", str(path), "-overwrite_original", "-q"], + check=False, timeout=CONVERT_TIMEOUT, + ) + + +def _to_4_2_0(src: Path, dest: Path) -> None: + """Re-encodes as a baseline JPEG with 4:2:0 chroma subsampling, + which ultrahdr_app requires for both the SDR and the gain map. Pillow + doesn't carry the source's ICC profile over on save unless it's passed + back in explicitly, which would silently flatten wide-gamut exports + (Display P3, Rec.2020) to sRGB.""" + with Image.open(src) as img: + icc_profile = img.info.get("icc_profile") + img.convert("RGB").save( + dest, format="JPEG", quality=95, subsampling=2, icc_profile=icc_profile + ) + + +def _encode( + hdr_raw: Path, hdr_gamut: str, sdr_420: Path, sdr_gamut: str, + width: int, height: int, out_path: Path, + max_boost: float | None = None, + hdr_transfer: str = "1", hdr_format: str = "5", +) -> None: + """Recomputes a fresh gain map between the raw HDR intent and the + given SDR base, and packages both into the final UltraHDR JPEG. + + hdr_transfer/hdr_format describe how hdr_raw is encoded: defaults + (hlg, rgba1010102) match _decode_hdr_intent's output for the JPEG + pipeline; the TIFF pipeline passes (linear, rgbahalffloat) instead. + + Without max_boost, ultrahdr_app fits the gain map's 8-bit code space to + the actual min/max HDR-to-SDR ratio found in the image. A handful of + near-black SDR pixels (JPEG noise, crushed shadows) can have a ratio + hundreds of times larger than every other pixel, which stretches that + 8-bit range to cover them and leaves barely any precision for the + ratios that actually matter, visible as flattened contrast and lost + detail. Passing max_boost clamps the code space to the real, + meaningful range instead. + + The floor is always 1 (no pixel is ever dimmed below its SDR value), + never max_boost's reciprocal: Instagram rejects (silently falls back + to plain SDR) files with a negative GainMapMin, which is what a + sub-1 floor produces. A few pixels that would ideally be dimmed a + little just render at their SDR brightness in HDR mode instead, + which is a much smaller loss than Instagram dropping HDR entirely. + + max_boost also sets -L (target display peak brightness in nits), + which is what hdrCapacityMax is derived from (nits / 203, the SDR + reference white ultrahdr_app assumes). Without it, -L keeps its + fixed default (1000 nits for hlg, 10000 for linear) regardless of + the image, so hdrCapacityMax ends up as a generic constant instead + of reflecting how much boost this file actually needs. Viewers that + auto-scale the applied boost against hdrCapacityMax (rather than + just showing maxContentBoost outright) under-apply the gain map + when that constant overstates the real requirement, the highlights + look barely boosted even though the gain map data itself is fine.""" + ultrahdr_app = shutil.which("ultrahdr_app") + args = [ + ultrahdr_app, "-m", "0", + "-p", str(hdr_raw), "-i", str(sdr_420), + "-w", str(width), "-h", str(height), + "-t", hdr_transfer, "-a", hdr_format, + "-C", hdr_gamut, "-c", sdr_gamut, + "-z", str(out_path), + ] + if max_boost is not None: + target_nits = min(max(max_boost * 203, 203), 10000) + args += ["-K", str(max_boost), "-k", "1", "-L", str(target_nits)] + result = subprocess.run( + args, capture_output=True, text=True, timeout=CONVERT_TIMEOUT, + ) + if result.returncode != 0: + raise AssemblyError(f"ultrahdr_app failed:\n{result.stderr}") + + +# HLG's nominal system gamma (ITU-R BT.2100), the OOTF exponent that maps +# scene light to display light for a reference viewing environment. +_HLG_SYSTEM_GAMMA = 1.2 + + +def _tiff_to_hdr_raw(tiff_path: Path, dest: Path) -> tuple[int, int, np.ndarray]: + """Converts a linear HDR intent TIFF into the RGBA half-float raw + buffer ultrahdr_app expects for a raw HDR input. Returns the + dimensions and the source array, the latter reused to compute the + real boost range needed directly from the floats. + + The TIFF's values are scene-linear (1.0 = SDR reference white), but + ultrahdr_app's "-t 0" (linear) mode treats -p as already + display-referred and applies no OOTF of its own, unlike "-t 1" (hlg), + which the JPEG pipeline uses and which carries HLG's OOTF built in. + Skipping it here left shadows visibly brighter and highlights + slightly under-boosted compared to Lightroom's own HLG-based gain + map, confirmed by comparing decoded values against it in both a dark + and a bright region: applying scene_linear ** gamma before encoding + matched Lightroom's own numbers far more closely in both.""" + linear = tifffile.imread(tiff_path) + display_linear = np.power(np.clip(linear, 0, None), _HLG_SYSTEM_GAMMA) + height, width, _ = display_linear.shape + rgba = np.empty((height, width, 4), dtype=np.float32) + rgba[:, :, :3] = display_linear + rgba[:, :, 3] = 1.0 + rgba.astype(np.float16).tofile(dest) + return width, height, display_linear + + +def _jpeg_to_linear(path: Path) -> np.ndarray: + """Decodes a JPEG to approximate linear light values (inverse sRGB + EOTF), so it can be compared directly against a TIFF's linear pixel + data. Only used to estimate a sane boost-clamp range, not for color + output, so treating any SDR gamut's transfer curve as sRGB-shaped is + close enough.""" + with Image.open(path) as img: + arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0 + return np.where(arr <= 0.04045, arr / 12.92, np.power((arr + 0.055) / 1.055, 2.4)) + + +def _tiff_max_boost(hdr_linear: np.ndarray, sdr_420: Path) -> float: + """The real per-pixel HDR/SDR ratio needed, computed from the source + TIFF floats directly rather than probed back out of an encoded gain + map. Pixels where the SDR is near-black are excluded: JPEG + compression noise there can produce a ratio hundreds of times larger + than anywhere else in the image, without reflecting real content. + + Everywhere else keeps its true ratio, but the true max alone is too + sensitive to a handful of pixels (a 4:2:0 subsampling/DCT artifact + right at a sharp edge can spike a couple of pixels far above + everything else). A genuine highlight (a light source, a reflection) + spans a real cluster of pixels, not a handful, so the 32nd-highest + ratio is used instead of the true max: high enough to still capture + small real highlights, past the point a few-pixel artifact reaches.""" + sdr_linear = _jpeg_to_linear(sdr_420) + real_signal = sdr_linear > 0.005 + ratio = hdr_linear[real_signal] / sdr_linear[real_signal] + n = min(32, ratio.size) + return float(np.partition(ratio, -n)[-n]) + + +def _probe_hdr_capacity(path: Path) -> float: + """Reads back the natural (unclamped) hdrCapacityMax ultrahdr_app + computed for an already-encoded UltraHDR JPEG.""" + result = subprocess.run( + [shutil.which("ultrahdr_app"), "-m", "1", "-j", str(path), "-P"], + capture_output=True, text=True, timeout=CONVERT_TIMEOUT, + ) + for line in result.stdout.splitlines(): + if line.strip().startswith("--hdrCapacityMax"): + return float(line.split()[1]) + raise AssemblyError("Could not read back gain map metadata after encoding.") + + +def _validate_output(out_path: Path, log: list[str]) -> bool: + """Appends the gain-map report to log; returns True if GainMapMin is + negative (the known symptom Instagram may reject the file for).""" + with tempfile.TemporaryDirectory() as tmpdir: + gainmap = Path(tmpdir) / "gainmap.jpg" + extract = subprocess.run( + ["exiftool", "-b", "-MPImage2", str(out_path)], + capture_output=True, timeout=CONVERT_TIMEOUT, + ) + if extract.returncode != 0 or not extract.stdout: + return False + gainmap.write_bytes(extract.stdout) + + report = subprocess.run( + ["exiftool", "-j", "-G1", "-XMP-hdrgm:all", str(gainmap)], + capture_output=True, text=True, timeout=CONVERT_TIMEOUT, + ) + if report.returncode != 0: + return False + try: + metadata = json.loads(report.stdout)[0] + except (json.JSONDecodeError, IndexError, KeyError): + return False + + log.append("--- Validation report ---") + for key, value in metadata.items(): + if "hdrgm" in key.lower() or "gainmap" in key.lower(): + log.append(f"{key}: {value}") + + gain_map_min = metadata.get("XMP-hdrgm:GainMapMin") + negative = gain_map_min is not None and float(gain_map_min) < 0 + if negative: + log.append( + "WARNING: GainMapMin is negative. Instagram may reject this file. " + "Try adjusting HDR export settings in Camera Raw." + ) + return negative + + +def _assemble_from_jpeg(sdr_path: Path, hdr_path: Path, out_path: Path, log: list[str], tmp: Path) -> None: + log.append("Decoding HDR intent from the HDR source...") + hdr_raw = tmp / "hdr_intent.raw" + _decode_hdr_intent(hdr_path, hdr_raw) + hdr_gamut = _detect_gamut(hdr_path) + + with Image.open(hdr_path) as hdr_img: + width, height = hdr_img.size + + sdr_clean = tmp / "sdr.jpg" + shutil.copy2(sdr_path, sdr_clean) + log.append("Cleaning SDR XMP...") + _strip_xmp(sdr_clean) + log.append("Converting SDR to 4:2:0 subsampling...") + sdr_420 = tmp / "sdr_420.jpg" + _to_4_2_0(sdr_clean, sdr_420) + sdr_gamut = _detect_gamut(sdr_420) + + log.append("Recomputing gain map against the SDR...") + probe_path = tmp / "probe.jpg" + _encode(hdr_raw, hdr_gamut, sdr_420, sdr_gamut, width, height, probe_path) + hdr_capacity = _probe_hdr_capacity(probe_path) + + log.append("Encoding the final ISO UltraHDR JPEG...") + _encode( + hdr_raw, hdr_gamut, sdr_420, sdr_gamut, width, height, out_path, + max_boost=hdr_capacity, + ) + + +def _assemble_from_tiff_hdr(sdr_path: Path, hdr_path: Path, out_path: Path, log: list[str], tmp: Path) -> None: + """Same idea as _assemble_from_jpeg, but the HDR intent comes straight + from Lightroom's linear HDR TIFF export instead of being decoded back + out of a gain-map JPEG. The SDR side is still a normal JPEG export, + handled exactly like the all-JPEG pipeline: mirrors how Lightroom's + own HDR export plugins do it (real SDR JPEG as base, separate TIFF + only for the HDR intent), and avoids re-deriving a viewable JPEG + (and its color-managed transfer curve) from linear TIFF data + ourselves.""" + log.append("Reading linear HDR intent from the TIFF...") + hdr_raw = tmp / "hdr_intent.raw" + width, height, hdr_linear = _tiff_to_hdr_raw(hdr_path, hdr_raw) + hdr_gamut = _detect_gamut(hdr_path) + + sdr_clean = tmp / "sdr.jpg" + shutil.copy2(sdr_path, sdr_clean) + log.append("Cleaning SDR XMP...") + _strip_xmp(sdr_clean) + log.append("Converting SDR to 4:2:0 subsampling...") + sdr_420 = tmp / "sdr_420.jpg" + _to_4_2_0(sdr_clean, sdr_420) + sdr_gamut = _detect_gamut(sdr_420) + + max_boost = _tiff_max_boost(hdr_linear, sdr_420) + + log.append("Encoding the final ISO UltraHDR JPEG...") + _encode( + hdr_raw, hdr_gamut, sdr_420, sdr_gamut, width, height, out_path, + max_boost=max_boost, hdr_transfer="0", hdr_format="4", + ) + + +def run(sdr_path: Path, hdr_path: Path, out_path: Path) -> tuple[bool, str, bool]: + """Assembles the HDR JPEG. Returns (success, log, gain_map_warning).""" + log: list[str] = [] + missing = check_dependencies() + if missing: + return False, f"Error: missing required tools on PATH: {', '.join(missing)}", False + + try: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + if hdr_path.suffix.lower() in TIFF_EXT: + _assemble_from_tiff_hdr(sdr_path, hdr_path, out_path, log, tmp) + else: + _assemble_from_jpeg(sdr_path, hdr_path, out_path, log, tmp) + + warning = _validate_output(out_path, log) + log.append(f"Done! HDR JPEG created at: {out_path}") + return True, "\n".join(log), warning + + except (AssemblyError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + log.append(f"Error: {e}") + return False, "\n".join(log), False diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9d84aa5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +"""Shared constants for the HDR-to-Instagram web front.""" +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +JOBS_DIR = BASE_DIR / "jobs" +VERSION = (BASE_DIR / "VERSION").read_text().strip() + +ALLOWED_SDR_EXT = {".jpg", ".jpeg"} +ALLOWED_HDR_EXT = {".jpg", ".jpeg", ".tif", ".tiff"} +TIFF_EXT = {".tif", ".tiff"} +MAX_CONTENT_LENGTH = 80 * 1024 * 1024 # 80 MB, uncompressed 32-bit float HDR TIFFs can be large + +# Instagram's exact feed resolutions — width x height, keyed by label. +IG_RESOLUTIONS = { + (1080, 1080): "1:1 square", + (1080, 1350): "4:5 portrait", + (1080, 566): "1.91:1 landscape", +} + +CONVERT_TIMEOUT = 300 # seconds, applied to each external tool call in assembler.py + +# The result page pings /heartbeat while open; a job whose last ping is +# older than HEARTBEAT_TIMEOUT is considered "left" and gets purged by the +# reaper thread. This survives page refreshes (pings resume) and doesn't +# depend on an unload event ever firing (tab kill, crash, lost network). +HEARTBEAT_TIMEOUT = 12 # seconds +REAPER_INTERVAL = 4 # seconds diff --git a/app/jobs.py b/app/jobs.py new file mode 100644 index 0000000..68e333b --- /dev/null +++ b/app/jobs.py @@ -0,0 +1,65 @@ +"""In-memory job registry and lifecycle (creation, heartbeat, reaping). + +Single-process only: JOBS lives in this module's memory, so this assumes +one gunicorn worker (see Dockerfile). Multiple workers would each keep a +separate registry and requests would 404 depending on which one handled +them. +""" +import shutil +import threading +import time +from pathlib import Path + +from .config import HEARTBEAT_TIMEOUT, JOBS_DIR, REAPER_INTERVAL + +JOBS: dict[str, dict] = {} + + +def reset_jobs_dir() -> None: + """Wipe any leftovers from a previous run (crash, forced restart) — + the in-memory registry never survives a restart anyway, so orphaned + job dirs on disk would otherwise sit there forever.""" + shutil.rmtree(JOBS_DIR, ignore_errors=True) + JOBS_DIR.mkdir(parents=True, exist_ok=True) + + +def new_job_dir(job_id: str) -> Path: + job_dir = JOBS_DIR / job_id + job_dir.mkdir(parents=True, exist_ok=True) + return job_dir + + +def register(job_id: str, **fields) -> None: + JOBS[job_id] = {**fields, "last_seen": time.time()} + + +def get(job_id: str) -> dict | None: + return JOBS.get(job_id) + + +def touch(job_id: str) -> None: + job = JOBS.get(job_id) + if job: + job["last_seen"] = time.time() + + +def discard_dir(job_dir: Path) -> None: + shutil.rmtree(job_dir, ignore_errors=True) + + +def cleanup(job_id: str) -> None: + JOBS.pop(job_id, None) + discard_dir(JOBS_DIR / job_id) + + +def _reap_stale_jobs() -> None: + while True: + time.sleep(REAPER_INTERVAL) + cutoff = time.time() - HEARTBEAT_TIMEOUT + stale = [jid for jid, job in list(JOBS.items()) if job.get("last_seen", 0) < cutoff] + for jid in stale: + cleanup(jid) + + +def start_reaper() -> None: + threading.Thread(target=_reap_stale_jobs, daemon=True).start() diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..743d1d0 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,129 @@ +"""Route handlers — wires the HTTP layer to jobs/validation/assembler.""" +import io +import uuid +import zipfile + +from flask import abort, redirect, render_template, request, send_file, url_for + +from . import assembler, jobs, validation + + +def _process_pair(job_dir, index, sdr_file, hdr_file): + """Runs one SDR/HDR pair through validation + assembly. Returns an item + dict for the job report; never raises — failures are recorded in it.""" + name = hdr_file.filename or sdr_file.filename or f"pair {index}" + + if not sdr_file.filename or not hdr_file.filename: + return {"name": name, "success": False, "warning": False, "output": None, + "log": "Missing SDR or HDR file for this pair."} + + ext_error = validation.sdr_ext_error(sdr_file.filename) or validation.hdr_ext_error(hdr_file.filename) + if ext_error: + return {"name": name, "success": False, "warning": False, "output": None, "log": ext_error} + + sdr_path = job_dir / f"sdr_{index}{validation.ext(sdr_file.filename)}" + hdr_path = job_dir / f"hdr_{index}{validation.ext(hdr_file.filename)}" + out_path = job_dir / f"instagram_hdr_output_{index}.jpg" + + sdr_file.save(sdr_path) + hdr_file.save(hdr_path) + + ig_error = validation.instagram_resolution_error(sdr_path, hdr_path) + if ig_error: + for p in (sdr_path, hdr_path): + p.unlink(missing_ok=True) + return {"name": name, "success": False, "warning": False, "output": None, "log": ig_error} + + success, log, warning = assembler.run(sdr_path, hdr_path, out_path) + for p in (sdr_path, hdr_path): + p.unlink(missing_ok=True) + + return { + "name": name, + "success": success, + "warning": warning, + "output": str(out_path) if success else None, + "log": log, + } + + +def register_routes(app): + @app.get("/") + def index(): + return render_template("index.html") + + @app.post("/convert") + def convert(): + sdr_files = request.files.getlist("sdr[]") + hdr_files = request.files.getlist("hdr[]") + + if not sdr_files or not hdr_files: + return render_template("index.html", error="Add at least one SDR/HDR pair."), 400 + if len(sdr_files) != len(hdr_files): + return render_template("index.html", error="Each pair needs both an SDR and an HDR file."), 400 + + job_id = uuid.uuid4().hex[:12] + job_dir = jobs.new_job_dir(job_id) + + items = [ + _process_pair(job_dir, i, sdr_file, hdr_file) + for i, (sdr_file, hdr_file) in enumerate(zip(sdr_files, hdr_files), start=1) + ] + + jobs.register(job_id, items=items) + return redirect(url_for("result", job_id=job_id)) + + @app.get("/result/") + def result(job_id): + job = jobs.get(job_id) + if not job: + abort(404) + items = job["items"] + ok_count = sum(1 for item in items if item["success"]) + total_count = len(items) + if ok_count == total_count and total_count == 1: + heading = "JPEG assembled" + elif ok_count == total_count: + heading = f"All {total_count} assembled" + elif ok_count == 0: + heading = "Conversion failed" + else: + heading = f"{ok_count}/{total_count} assembled" + return render_template( + "result.html", + job_id=job_id, + items=items, + ok_count=ok_count, + total_count=total_count, + heading=heading, + ) + + @app.get("/download/") + def download(job_id): + job = jobs.get(job_id) + if not job: + abort(404) + successes = [item for item in job["items"] if item["success"] and item["output"]] + if not successes: + abort(404) + + if len(successes) == 1: + return send_file(successes[0]["output"], as_attachment=True, download_name="instagram_hdr_output.jpg") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for i, item in enumerate(successes, start=1): + zf.write(item["output"], arcname=f"instagram_hdr_output_{i}.jpg") + buf.seek(0) + return send_file(buf, as_attachment=True, download_name="instagram_hdr_outputs.zip", mimetype="application/zip") + + @app.post("/heartbeat/") + def heartbeat(job_id): + # Pinged by the result page while it's open/visible. Silently + # ignored for unknown/already-reaped job_ids — nothing to keep alive. + jobs.touch(job_id) + return ("", 204) + + @app.errorhandler(413) + def too_large(_e): + return render_template("index.html", error="File too large (max 80 MB)."), 413 diff --git a/app/validation.py b/app/validation.py new file mode 100644 index 0000000..b0b6613 --- /dev/null +++ b/app/validation.py @@ -0,0 +1,64 @@ +"""Upload validation: file extensions and Instagram's exact feed resolutions.""" +from pathlib import Path + +import tifffile +from PIL import Image, UnidentifiedImageError + +from .config import ALLOWED_HDR_EXT, ALLOWED_SDR_EXT, IG_RESOLUTIONS, TIFF_EXT + + +def ext(filename: str) -> str: + return Path(filename).suffix.lower() + + +def is_tiff(filename: str) -> bool: + return ext(filename) in TIFF_EXT + + +def sdr_ext_error(filename: str) -> str | None: + if ext(filename) not in ALLOWED_SDR_EXT: + return "The SDR file must be a .jpg/.jpeg." + return None + + +def hdr_ext_error(filename: str) -> str | None: + if ext(filename) not in ALLOWED_HDR_EXT: + return "The HDR file must be a .jpg/.jpeg (existing gain map) or a 32-bit float .tif/.tiff." + return None + + +def probe_dimensions(path: Path) -> tuple[int, int]: + if is_tiff(path.name): + try: + with tifffile.TiffFile(path) as tif: + page = tif.pages[0] + return (page.imagewidth, page.imagelength) + except Exception as e: + raise ValueError(f"could not read TIFF dimensions ({e})") from e + try: + with Image.open(path) as img: + return img.size + except (UnidentifiedImageError, OSError) as e: + raise ValueError(f"could not read image dimensions ({e})") from e + + +def instagram_resolution_error(sdr_path: Path, hdr_path: Path) -> str | None: + """Returns an error message if the pair doesn't meet Instagram's feed + requirements, otherwise None.""" + try: + sdr_w, sdr_h = probe_dimensions(sdr_path) + hdr_w, hdr_h = probe_dimensions(hdr_path) + except ValueError as e: + return f"Could not validate image dimensions: {e}." + + if (sdr_w, sdr_h) != (hdr_w, hdr_h): + return ( + f"SDR ({sdr_w}x{sdr_h}) and HDR ({hdr_w}x{hdr_h}) must have identical " + "dimensions, otherwise the gain map will misalign." + ) + + if (sdr_w, sdr_h) not in IG_RESOLUTIONS: + allowed = ", ".join(f"{w}x{h} ({label})" for (w, h), label in IG_RESOLUTIONS.items()) + return f"Image is {sdr_w}x{sdr_h} — Instagram requires an exact match to one of: {allowed}." + + return None diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..8f725d2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 + +# ---- Stage 1: compile libultrahdr (ultrahdr_app) -------------------------- +FROM alpine:3.21 AS uhdr-build + +RUN apk add --no-cache \ + git cmake make g++ nasm pkgconf libjpeg-turbo-dev + +WORKDIR /build +# Pin to a commit once you've confirmed it works for you — HEAD can move. +RUN git clone --depth 1 https://github.com/google/libultrahdr.git + +RUN cmake -G "Unix Makefiles" -DUHDR_WRITE_XMP=ON \ + -S libultrahdr -B libultrahdr/build \ + && cmake --build libultrahdr/build -j"$(nproc)" + +# ---- Stage 2: runtime ------------------------------------------------------- +FROM python:3.11-alpine + +RUN apk add --no-cache exiftool libjpeg-turbo libstdc++ + +COPY --from=uhdr-build /build/libultrahdr/build/ultrahdr_app /usr/local/bin/ultrahdr_app +RUN chmod +x /usr/local/bin/ultrahdr_app + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . +COPY app/ app/ +COPY templates/ templates/ +COPY static/ static/ +COPY LICENSE VERSION ./ +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +RUN adduser -D runner && mkdir -p /app/jobs && chown -R runner:runner /app +USER runner + +EXPOSE 5000 +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/Dockerfile.dockerignore b/docker/Dockerfile.dockerignore new file mode 100644 index 0000000..d68eaf5 --- /dev/null +++ b/docker/Dockerfile.dockerignore @@ -0,0 +1,5 @@ +jobs/* +!jobs/.gitkeep +__pycache__/ +*.pyc +.git diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 0000000..1fcbcfa --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,15 @@ +name: instagram-hdr + +services: + hdr-web: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: instagram-hdr-web + restart: unless-stopped + ports: + - "5050:5000" # host:container — change 5050 if that's taken too + # Put a reverse proxy (Traefik/nginx/SWAG) in front of this if you + # want it reachable outside your LAN — the Flask app itself has no + # auth and no rate limiting, so keep it internal-only or add both + # before exposing it further than your own network. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..3123c48 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +CYAN="\033[1;36m" +NC="\033[0m" + +VERSION=$(cat /app/VERSION) + +echo -e "${CYAN}╭──────────────────────────────────────────────────╮${NC}" +echo -e "${CYAN}│${NC} Instagram ${CYAN}HDR${NC} Assembler — version ${VERSION} ${CYAN}│${NC}" +echo -e "${CYAN}├──────────────────────────────────────────────────┤${NC}" +echo -e "${CYAN}│${NC} License: MIT (see LICENSE) ${CYAN}│${NC}" +echo -e "${CYAN}│${NC} Gain-map pipeline adapted from: ${CYAN}│${NC}" +echo -e "${CYAN}│${NC} kostis-kounadis/instagram-hdr-assembler ${CYAN}│${NC}" +echo -e "${CYAN}╰──────────────────────────────────────────────────╯${NC}" + +echo -e "[~] Checking required external tools..." +missing="" +for tool in ultrahdr_app exiftool; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing="$missing $tool" + fi +done +if [ -n "$missing" ]; then + echo -e "[!] Missing required tools:$missing" + exit 1 +fi +echo -e "[✓] Required tools found: ultrahdr_app, exiftool" + +echo -e "[~] Starting Instagram HDR Assembler web server..." +exec gunicorn --bind 0.0.0.0:5000 --workers 1 --threads 4 --timeout 120 main:app diff --git a/jobs/.gitkeep b/jobs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..07b91e4 --- /dev/null +++ b/main.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +""" +Instagram HDR Assembler — Web Front +------------------------------------ +Flask front end for assembling Instagram-compliant HDR JPEGs (gain map +injection adapted from kostis-kounadis/instagram-hdr-assembler — see +LICENSE). Gives you a browser form instead of a terminal, and +surfaces the assembly report (including the "GainMapMin is negative" +warning) on a results page. + +This file is just the entry point — see app/ for the app itself: + app/config.py constants + app/jobs.py in-memory job registry + heartbeat/reaper + app/validation.py upload + Instagram resolution checks + app/assembler.py gain-map assembly pipeline + app/routes.py Flask routes + +Run directly: python3 main.py +Run in Docker: see Dockerfile (gunicorn main:app) +""" +import os +import sys + +from app import create_app +from app.assembler import check_dependencies + +app = create_app() + +if __name__ == "__main__": + missing = check_dependencies() + if missing: + print(f"ERROR: missing required tools on PATH: {', '.join(missing)}", file=sys.stderr) + sys.exit(1) + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5052222 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.3 +gunicorn==23.0.0 +Pillow==11.1.0 +numpy==2.4.6 +tifffile==2026.3.3 diff --git a/screenshots/insta-hdr.png b/screenshots/insta-hdr.png new file mode 100644 index 0000000..9e1448e Binary files /dev/null and b/screenshots/insta-hdr.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b812a1b --- /dev/null +++ b/static/style.css @@ -0,0 +1,303 @@ +:root { + --bg: #14161a; + --surface: #1c1f25; + --surface-raised: #242830; + --border: #33373f; + --text: #eef1f4; + --text-muted: #8b93a0; + --accent: #2dd4bf; + --accent-dim: #2a8577; + --ok: #7fa37a; + --err: #c4604a; + --serif: Charter, "Iowan Old Style", "Palatino Linotype", Georgia, ui-serif, serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --sans: -apple-system, "Segoe UI", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 3rem 1.5rem 5rem; + background: var(--bg); + background-image: + radial-gradient(ellipse 900px 500px at 50% -10%, rgba(45, 212, 191, 0.07), transparent); + color: var(--text); + font-family: var(--sans); + line-height: 1.5; + min-height: 100vh; +} + +.wrap { + max-width: 640px; + margin: 0 auto; +} + +.eyebrow { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 0.6rem; +} + +h1 { + font-family: var(--serif); + font-weight: 500; + font-size: 2.1rem; + line-height: 1.15; + margin: 0 0 0.5rem; + color: var(--text); +} + +.subtitle { + color: var(--text-muted); + font-size: 0.95rem; + margin: 0 0 2.5rem; + max-width: 46ch; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.75rem; +} + +.pair-card + .pair-card { + margin-top: 1rem; +} + +form { display: block; } + +.duo { + position: relative; + margin-bottom: 1.25rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.duo:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.remove-duo { + position: absolute; + top: -0.4rem; + right: -0.4rem; + width: 1.5rem; + height: 1.5rem; + line-height: 1; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--surface-raised); + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + z-index: 1; +} + +.duo:first-child .remove-duo { display: none; } +.remove-duo:hover { color: var(--err); border-color: var(--err); } + +.add-duo { + display: block; + width: 100%; + background: transparent; + border: 1px dashed var(--border); + color: var(--text-muted); + padding: 0.6rem 1rem; + border-radius: 7px; + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} + +.add-duo:hover { color: var(--text); border-color: var(--accent-dim); } + +.dropzones { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.25rem; +} + +@media (max-width: 520px) { + .dropzones { grid-template-columns: 1fr; } +} + +.dropzone { + position: relative; + border: 1px dashed var(--border); + border-radius: 8px; + padding: 1.25rem 1rem; + text-align: center; + transition: border-color 0.15s ease, background 0.15s ease; + background: var(--surface-raised); +} + +.dropzone:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.dropzone.has-file { + border-color: var(--accent-dim); + border-style: solid; +} + +.dropzone label.tag { + display: block; + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 0.5rem; +} + +.dropzone .hint { + color: var(--text-muted); + font-size: 0.8rem; +} + +.dropzone .filename { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--text); + word-break: break-all; + margin-top: 0.35rem; + display: none; +} + +.dropzone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} + +button.run { + width: 100%; + margin-top: 1.5rem; + background: var(--accent); + color: #06211d; + border: none; + padding: 0.75rem 1rem; + border-radius: 7px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; +} + +button.run:hover { background: #5eead4; } +button.run:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } + +.error-banner { + background: rgba(196, 96, 74, 0.12); + border: 1px solid rgba(196, 96, 74, 0.4); + color: #e2a795; + padding: 0.7rem 0.9rem; + border-radius: 7px; + font-size: 0.85rem; + margin-bottom: 1.25rem; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--mono); + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.35rem 0.7rem; + border-radius: 5px; + margin-bottom: 1.25rem; +} + +.status.ok { background: rgba(127, 163, 122, 0.14); color: var(--ok); } +.status.err { background: rgba(196, 96, 74, 0.14); color: var(--err); } + +.warning-banner { + background: rgba(45, 212, 191, 0.1); + border: 1px solid rgba(45, 212, 191, 0.35); + color: var(--accent); + padding: 0.7rem 0.9rem; + border-radius: 7px; + font-size: 0.85rem; + margin-bottom: 1.25rem; +} + +pre.log { + background: #0e1015; + border: 1px solid var(--border); + border-radius: 7px; + padding: 1rem; + font-family: var(--mono); + font-size: 0.76rem; + color: #b6c0c8; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 360px; + overflow-y: auto; +} + +.actions { + display: flex; + gap: 0.75rem; + margin-top: 1.25rem; + flex-wrap: wrap; +} + +.btn-download, +.btn-secondary { + display: inline-block; + text-decoration: none; + padding: 0.65rem 1.1rem; + border-radius: 7px; + font-size: 0.88rem; + font-weight: 600; +} + +.btn-download { + background: var(--accent); + color: #06211d; +} +.btn-download:hover { background: #5eead4; } + +.btn-secondary { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} +.btn-secondary:hover { color: var(--text); border-color: var(--accent-dim); } + +.footnote { + margin-top: 2rem; + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.6; +} + +.footnote code { + font-family: var(--mono); + background: var(--surface-raised); + padding: 0.1rem 0.35rem; + border-radius: 4px; +} + +.version { + margin-top: 0.75rem; + color: var(--text-muted); + font-family: var(--mono); + font-size: 0.7rem; + opacity: 0.6; +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..321a298 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,108 @@ + + + + + + Instagram HDR Assembler + + + +
+

Instagram HDR Assembler

+

Build your HDR JPEG for Instagram

+

Drop your SDR export and your HDR export from Lightroom / Camera Raw. The script does the rest — gain map, 4:2:0 subsampling, Instagram metadata injection. Add more pairs to process several photos in one go.

+ +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+
+ +
+
+ SDR fallback +
.jpg / .jpeg
+
+ +
+
+ HDR export +
.jpg (gain map), or 32-bit float .tif
+
+ +
+
+
+
+ + + + +
+
+ +

+ Each pair's files must have identical dimensions, matching exactly one of Instagram's feed + resolutions: 1080x1080 (1:1), 1080x1350 (4:5), or + 1080x566 (1.91:1). Assembling more than one pair downloads as a .zip. + HDR can be a regular gain-map JPEG or Lightroom's 32-bit float linear HDR TIFF export; a + TIFF HDR file gives a more precise result than the JPEG gain-map route. +

+

Instagram HDR Assembler v{{ app_version }}

+
+ + + + diff --git a/templates/result.html b/templates/result.html new file mode 100644 index 0000000..c328962 --- /dev/null +++ b/templates/result.html @@ -0,0 +1,65 @@ + + + + + + Instagram HDR Assembler — Result + + + +
+

Conversion report

+

{{ heading }}

+ + {% for item in items %} +
+ + {{ "✓ success" if item.success else "✗ error" }}{% if total_count > 1 %} — {{ item.name }}{% endif %} + + + {% if item.warning %} +
+ ⚠ The report flags a negative GainMapMin — that's the same symptom as + the bug reported on Lightroom Mobile: Instagram may reject this file despite a + "successful" conversion. Worth tweaking the HDR export settings before + re-uploading if the upload fails. +
+ {% endif %} + +
{{ item.log }}
+
+ {% endfor %} + + + +

+ Remember to upload from a desktop browser (up-to-date Chrome) afterwards, choosing + Original as the crop — not the default 1:1 crop. +

+

Instagram HDR Assembler v{{ app_version }}

+
+ + + +