Reorganize into src/ with script/assets/templates, illustration/ at root
New layout:
src/script/app/ Python package (was app/)
src/assets/img/ SVGs (was static/*.svg)
src/assets/css/ stylesheet (was static/style.css)
src/templates/ HTML templates (was templates/)
illustration/ README screenshot (was screenshots/), kept at
the repo root since it's a doc asset, not
something the app serves
main.py, jobs/, VERSION, docker/ unchanged locations
main.py stays at the repo root as the entry point (matches how
Docker and local runs already invoke it) and prepends src/script to
sys.path before importing app, rather than moving into src/script/
itself and needing every entry point updated to match.
config.py's path constants are recomputed for app/config.py's new
depth (src/script/app/), with SRC_DIR and REPO_ROOT resolved
explicitly rather than a single ambiguous BASE_DIR, since "templates
and assets live under src/" and "jobs and VERSION live at the repo
root" are no longer the same directory. Flask's static_folder now
points at src/assets, which serves img/ and css/ under it at
whatever URL prefix Flask derives from the folder name (/assets, not
/static); templates already reference assets via the "static"
endpoint name through url_for, so this needed the url_for() calls'
filename argument updated with the img/ or css/ prefix, not a
hardcoded URL change.
Docker copies src/ into the image preserving this same internal
structure, so the path arithmetic in config.py resolves identically
in both a local checkout and the container, no --chdir or other
container-specific path handling needed.
Verified end to end after the move: local Flask dev server, a full
Docker rebuild, and a real HTTP conversion request through the
running container (TIFF HDR pipeline, numpy/tifffile imports
included) all succeed.
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
:root {
|
||||
--bg: #111010;
|
||||
--surface: rgb(67 67 67 / 26%);
|
||||
--surface-raised: #1f2223;
|
||||
--surface-focus: #161616;
|
||||
--border: #2f2e2e80;
|
||||
--border-solid: #585858;
|
||||
--text: #fbfbfb;
|
||||
--text-muted: #9a9a9a;
|
||||
--placeholder: #585858;
|
||||
--accent: #55c3ec;
|
||||
--accent-start: #26c4ff;
|
||||
--accent-end: #016074;
|
||||
--accent-hover-start: #72d9ff;
|
||||
--accent-hover-end: #26657e;
|
||||
--gold: #ffc700;
|
||||
--ok: #049b3d;
|
||||
--ok-bright: #28c76f;
|
||||
--err: #dc3545;
|
||||
--err-dim: rgb(121 26 19);
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 3rem 1.5rem 5rem;
|
||||
background: var(--bg);
|
||||
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(--sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--sans);
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 2rem;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
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-solid);
|
||||
border-radius: 50%;
|
||||
background: #2d2d2d;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.duo:first-child .remove-duo { display: none; }
|
||||
.remove-duo:hover { background: var(--err-dim); color: var(--text); }
|
||||
|
||||
.add-duo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-solid);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.add-duo:hover { background: #2d2d2d; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-row input { cursor: pointer; accent-color: var(--accent); }
|
||||
|
||||
.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-solid);
|
||||
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) {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-focus);
|
||||
}
|
||||
|
||||
.dropzone.has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dropzone .tag {
|
||||
display: block;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
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: linear-gradient(135deg, var(--accent-start), var(--accent-end));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(38, 196, 255, 0.15);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button.run:hover { background: linear-gradient(135deg, var(--accent-hover-start), var(--accent-hover-end)); }
|
||||
button.run:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
|
||||
.error-banner {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border: 1px solid rgba(220, 53, 69, 0.4);
|
||||
color: #f28b95;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--sans);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 30px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.status.ok { background: rgba(4, 155, 61, 0.16); color: var(--ok-bright); }
|
||||
.status.err { background: rgba(220, 53, 69, 0.14); color: #f28b95; }
|
||||
|
||||
.warning-banner {
|
||||
background: rgba(255, 199, 0, 0.1);
|
||||
border: 1px solid rgba(255, 199, 0, 0.35);
|
||||
color: var(--gold);
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
pre.log {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.76rem;
|
||||
color: #c7c7c7;
|
||||
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.3rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: linear-gradient(135deg, var(--accent-start), var(--accent-end));
|
||||
color: #fff;
|
||||
}
|
||||
.btn-download:hover { background: linear-gradient(135deg, var(--accent-hover-start), var(--accent-hover-end)); }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-solid);
|
||||
}
|
||||
.btn-secondary:hover { background: #2d2d2d; }
|
||||
|
||||
.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);
|
||||
color: var(--accent);
|
||||
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;
|
||||
}
|
||||
|
||||
.repo-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.repo-links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.repo-links a:hover { color: var(--text); }
|
||||
|
||||
.repo-links .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.repo-links .icon img { width: 100%; height: 100%; }
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#48cf51ff" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea icon</title><path d="M4.186 5.421C2.341 5.417-.13 6.59.006 9.531c.213 4.594 4.92 5.02 6.801 5.057.206.862 2.42 3.834 4.059 3.99h7.18c4.306-.286 7.53-13.022 5.14-13.07-3.953.186-6.296.28-8.305.296v3.975l-.626-.277-.004-3.696c-2.306-.001-4.336-.108-8.189-.298-.482-.003-1.154-.085-1.876-.087zm.261 1.625h.22c.262 2.355.688 3.732 1.55 5.836-2.2-.26-4.072-.899-4.416-3.285-.178-1.235.422-2.524 2.646-2.552zm8.557 2.315c.15.002.303.03.447.096l.749.323-.537.979a.672.597 0 0 0-.241.038.672.597 0 0 0-.405.764.672.597 0 0 0 .112.174l-.926 1.686a.672.597 0 0 0-.222.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.765.672.597 0 0 0-.158-.22l.902-1.642a.672.597 0 0 0 .293-.03.672.597 0 0 0 .213-.112c.348.146.633.265.838.366.308.152.417.253.45.365.033.11-.003.322-.177.694-.13.277-.345.67-.599 1.133a.672.597 0 0 0-.251.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.764.672.597 0 0 0-.137-.202c.251-.458.467-.852.606-1.148.188-.402.286-.701.2-.99-.086-.289-.35-.477-.7-.65-.23-.113-.517-.233-.86-.377a.672.597 0 0 0-.038-.239.672.597 0 0 0-.145-.209l.528-.963 2.924 1.263c.528.229.746.79.49 1.26l-2.01 3.68c-.257.469-.888.663-1.416.435l-4.137-1.788c-.528-.228-.747-.79-.49-1.26l2.01-3.679c.176-.323.53-.515.905-.53h.064z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
|
||||
<title>github [#142]</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="#ffffffff">
|
||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||
<path d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399" id="github-[#142]">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,26 @@
|
||||
"""HDR-to-Instagram web front — Flask app factory."""
|
||||
from flask import Flask
|
||||
|
||||
from . import jobs
|
||||
from .config import ASSETS_DIR, MAX_CONTENT_LENGTH, TEMPLATES_DIR, VERSION
|
||||
from .routes import register_routes
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(TEMPLATES_DIR),
|
||||
static_folder=str(ASSETS_DIR),
|
||||
)
|
||||
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,32 @@
|
||||
"""Shared constants for the HDR-to-Instagram web front."""
|
||||
from pathlib import Path
|
||||
|
||||
# This file lives at <repo root>/src/script/app/config.py.
|
||||
SRC_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
REPO_ROOT = SRC_DIR.parent
|
||||
|
||||
TEMPLATES_DIR = SRC_DIR / "templates"
|
||||
ASSETS_DIR = SRC_DIR / "assets"
|
||||
JOBS_DIR = REPO_ROOT / "jobs"
|
||||
VERSION = (REPO_ROOT / "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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""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, ignore_ig_resolution=False):
|
||||
"""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, ignore_ig_resolution)
|
||||
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
|
||||
|
||||
ignore_ig_resolution = request.form.get("ignore_ig_resolution") == "on"
|
||||
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
job_dir = jobs.new_job_dir(job_id)
|
||||
|
||||
items = [
|
||||
_process_pair(job_dir, i, sdr_file, hdr_file, ignore_ig_resolution)
|
||||
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,72 @@
|
||||
"""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, ignore_ig_resolution: bool = False,
|
||||
) -> str | None:
|
||||
"""Returns an error message if the pair doesn't meet Instagram's feed
|
||||
requirements, otherwise None.
|
||||
|
||||
The dimension-match check always applies: mismatched SDR/HDR
|
||||
dimensions misalign the gain map regardless of what platform the
|
||||
result is for. ignore_ig_resolution skips only the exact-match check
|
||||
against Instagram's own feed resolutions, for results meant for
|
||||
somewhere other than Instagram."""
|
||||
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 not ignore_ig_resolution and (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,125 @@
|
||||
<!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='css/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>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="ignore_ig_resolution">
|
||||
Process anyway if the resolution doesn't match Instagram's feed sizes
|
||||
</label>
|
||||
|
||||
<button class="run" type="submit">Assemble the HDR</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
Each pair's files must have identical dimensions. By default that must also match one of
|
||||
Instagram's feed resolutions exactly: <strong>1080x1080</strong> (1:1),
|
||||
<strong>1080x1350</strong> (4:5), or <strong>1080x566</strong> (1.91:1); check the box above
|
||||
to skip that check for results meant for somewhere other than Instagram. 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 class="repo-links">
|
||||
<a href="https://git.djeex.fr/Djeex/insta-hdr-converter" target="_blank" rel="noopener">
|
||||
<span class="icon"><img src="{{ url_for('static', filename='img/gitea.svg') }}" alt=""></span>
|
||||
Gitea
|
||||
</a>
|
||||
<a href="https://github.com/Djeex/insta-hdr-converter" target="_blank" rel="noopener">
|
||||
<span class="icon"><img src="{{ url_for('static', filename='img/github.svg') }}" alt=""></span>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</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,75 @@
|
||||
<!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='css/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 class="repo-links">
|
||||
<a href="https://git.djeex.fr/Djeex/insta-hdr-converter" target="_blank" rel="noopener">
|
||||
<span class="icon"><img src="{{ url_for('static', filename='img/gitea.svg') }}" alt=""></span>
|
||||
Gitea
|
||||
</a>
|
||||
<a href="https://github.com/Djeex/insta-hdr-converter" target="_blank" rel="noopener">
|
||||
<span class="icon"><img src="{{ url_for('static', filename='img/github.svg') }}" alt=""></span>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</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