1st commit
This commit is contained in:
+19
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
jobs/*
|
||||
!jobs/.gitkeep
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git
|
||||
@@ -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.
|
||||
Executable
+31
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user