1st commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user