1st commit

This commit is contained in:
Djeex
2026-07-30 22:31:34 +02:00
commit c93344739a
21 changed files with 1418 additions and 0 deletions
+19
View File
@@ -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/
+33
View File
@@ -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
+78
View File
@@ -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://<host>: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.
+1
View File
@@ -0,0 +1 @@
1.0.0
+26
View File
@@ -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
+368
View File
@@ -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
+27
View File
@@ -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
+65
View File
@@ -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()
+129
View File
@@ -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/<job_id>")
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/<job_id>")
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/<job_id>")
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
+64
View File
@@ -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
+41
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
jobs/*
!jobs/.gitkeep
__pycache__/
*.pyc
.git
+15
View File
@@ -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.
+31
View File
@@ -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
View File
+35
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
Flask==3.1.3
gunicorn==23.0.0
Pillow==11.1.0
numpy==2.4.6
tifffile==2026.3.3
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

+303
View File
@@ -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;
}
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Instagram HDR Assembler</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="wrap">
<p class="eyebrow">Instagram HDR Assembler</p>
<h1>Build your HDR JPEG for Instagram</h1>
<p class="subtitle">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.</p>
<div class="card">
{% if error %}
<div class="error-banner">{{ error }}</div>
{% endif %}
<form action="/convert" method="post" enctype="multipart/form-data">
<div id="duos">
<div class="duo">
<button type="button" class="remove-duo" title="Remove this pair" aria-label="Remove this pair">&times;</button>
<div class="dropzones">
<div class="dropzone" data-zone="sdr">
<span class="tag">SDR fallback</span>
<div class="hint">.jpg / .jpeg</div>
<div class="filename"></div>
<input type="file" name="sdr[]" accept=".jpg,.jpeg" required>
</div>
<div class="dropzone" data-zone="hdr">
<span class="tag">HDR export</span>
<div class="hint">.jpg (gain map), or 32-bit float .tif</div>
<div class="filename"></div>
<input type="file" name="hdr[]" accept=".jpg,.jpeg,.tif,.tiff" required>
</div>
</div>
</div>
</div>
<button type="button" class="add-duo" id="add-duo">+ Add another pair</button>
<button class="run" type="submit">Assemble the HDR</button>
</form>
</div>
<p class="footnote">
Each pair's files must have identical dimensions, matching exactly one of Instagram's feed
resolutions: <strong>1080x1080</strong> (1:1), <strong>1080x1350</strong> (4:5), or
<strong>1080x566</strong> (1.91:1). Assembling more than one pair downloads as a <code>.zip</code>.
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.
</p>
<p class="version">Instagram HDR Assembler v{{ app_version }}</p>
</div>
<script>
(function () {
var duosContainer = document.getElementById('duos');
var addBtn = document.getElementById('add-duo');
function updateFilename(zone) {
var input = zone.querySelector('input[type="file"]');
var filenameEl = zone.querySelector('.filename');
if (input.files && input.files.length > 0) {
zone.classList.add('has-file');
filenameEl.textContent = input.files[0].name;
filenameEl.style.display = 'block';
} else {
zone.classList.remove('has-file');
filenameEl.style.display = 'none';
}
}
duosContainer.addEventListener('change', function (e) {
var zone = e.target.closest('.dropzone');
if (zone) updateFilename(zone);
});
['dragover', 'dragenter'].forEach(function (evt) {
duosContainer.addEventListener(evt, function (e) {
var zone = e.target.closest('.dropzone');
if (zone) { e.preventDefault(); zone.classList.add('has-file'); }
});
});
duosContainer.addEventListener('click', function (e) {
if (!e.target.classList.contains('remove-duo')) return;
var duos = duosContainer.querySelectorAll('.duo');
if (duos.length > 1) {
e.target.closest('.duo').remove();
}
});
addBtn.addEventListener('click', function () {
var clone = duosContainer.querySelector('.duo').cloneNode(true);
clone.querySelectorAll('input[type="file"]').forEach(function (input) { input.value = ''; });
clone.querySelectorAll('.filename').forEach(function (el) {
el.textContent = '';
el.style.display = 'none';
});
clone.querySelectorAll('.dropzone').forEach(function (zone) { zone.classList.remove('has-file'); });
duosContainer.appendChild(clone);
});
})();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Instagram HDR Assembler — Result</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="wrap">
<p class="eyebrow">Conversion report</p>
<h1>{{ heading }}</h1>
{% for item in items %}
<div class="card pair-card">
<span class="status {{ 'ok' if item.success else 'err' }}">
{{ "✓ success" if item.success else "✗ error" }}{% if total_count > 1 %} — {{ item.name }}{% endif %}
</span>
{% if item.warning %}
<div class="warning-banner">
⚠ The report flags a <strong>negative GainMapMin</strong> — 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.
</div>
{% endif %}
<pre class="log">{{ item.log }}</pre>
</div>
{% endfor %}
<div class="actions">
{% if ok_count > 0 %}
<a class="btn-download" href="{{ url_for('download', job_id=job_id) }}">
{{ "Download the JPEG" if ok_count == 1 else "Download .zip (" ~ ok_count ~ " files)" }}
</a>
{% endif %}
<a class="btn-secondary" href="{{ url_for('index') }}">← New conversion</a>
</div>
<p class="footnote">
Remember to upload from a desktop browser (up-to-date Chrome) afterwards, choosing
<code>Original</code> as the crop — not the default 1:1 crop.
</p>
<p class="version">Instagram HDR Assembler v{{ app_version }}</p>
</div>
<script>
(function () {
var jobId = {{ job_id | tojson }};
// Keeps the job's files alive while this page is open. If pings
// stop (tab closed, navigated away) the server purges the job a
// few seconds later. A refresh just resumes pinging, so it's safe.
function ping() {
if (document.visibilityState === 'visible') {
fetch('/heartbeat/' + jobId, { method: 'POST', keepalive: true }).catch(function () {});
}
}
ping();
setInterval(ping, 4000);
})();
</script>
</body>
</html>