diff --git a/README.md b/README.md index 8199271..cbbff96 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,32 @@ + +
+ Instameex logo +
+

+

+

Mix your SDR and HDR exports into an Instagram-ready HDR photo.

+
+

+
+ Instameex screenshot +
+ + > [!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._ +> _Github repo is a mirror of https://git.djeex.fr/Djeex/instameex. You'll find full package, history and release note there._ -# Instagram HDR Assembler +# Instameex 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 +The gain-map assembly logic ([src/script/app/assembler.py](src/script/app/assembler.py)) is adapted from [kostis-kounadis/instagram-hdr-assembler](https://github.com/kostis-kounadis/instagram-hdr-assembler), itself based on the reverse-engineering work of [karachungen/instagram-hdr-converter](https://github.com/karachungen/instagram-hdr-converter). Original MIT license kept in [LICENSE](LICENSE). -Only the upstream's Method 2 (starting from an HDR JPEG that already -carries a gain map) is implemented here. Method 1's AVIF decoding and -its quality/transfer/gamut settings were dropped, but the HDR side can -still be a 32-bit float linear TIFF (Lightroom's HDR TIFF export) -instead of a gain-map JPEG for a more precise result. Either way the -gain map is recomputed rather than reused as-is (see below), since it -needs to be correct against the SDR file you actually provide. - -![Instagram HDR Assembler screenshot](screenshots/insta-hdr.png) - ## How it works 1. Upload an SDR file (`.jpg`/`.jpeg`) and an HDR file: either a gain-map diff --git a/VERSION b/VERSION index afaf360..1cc5f65 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 \ No newline at end of file +1.1.0 \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 8f725d2..d325917 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,9 +27,7 @@ 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 src/ src/ COPY LICENSE VERSION ./ COPY docker/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/docker/compose.yaml b/docker/compose.yaml index 1fcbcfa..71caab7 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -1,15 +1,12 @@ -name: instagram-hdr +name: instameex services: hdr-web: build: context: .. dockerfile: docker/Dockerfile - container_name: instagram-hdr-web + image: instameex + container_name: instameex 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. + - "5050:5000" # host:container — change 5050 if that's taken too \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3123c48..f11b8c2 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -6,13 +6,12 @@ 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 "${CYAN}╭──────────────────────────────────────────────╮${NC}" +echo -e "${CYAN}│${NC} Instam${CYAN}eex${NC} - Version ${VERSION} ${CYAN}│${NC}" +echo -e "${CYAN}├──────────────────────────────────────────────┤${NC}" +echo -e "${CYAN}│${NC} Source: https://git.djeex.fr/Djeex/instameex ${CYAN}│${NC}" +echo -e "${CYAN}│${NC} Mirror: https://github.com/Djeex/instameex ${CYAN}│${NC}" +echo -e "${CYAN}╰──────────────────────────────────────────────╯${NC}" echo -e "[~] Checking required external tools..." missing="" @@ -27,5 +26,5 @@ if [ -n "$missing" ]; then fi echo -e "[✓] Required tools found: ultrahdr_app, exiftool" -echo -e "[~] Starting Instagram HDR Assembler web server..." +echo -e "[~] Starting Instameex web server..." exec gunicorn --bind 0.0.0.0:5000 --workers 1 --threads 4 --timeout 120 main:app diff --git a/illustration/instameex-illustration.png b/illustration/instameex-illustration.png new file mode 100644 index 0000000..5205aef Binary files /dev/null and b/illustration/instameex-illustration.png differ diff --git a/jobs/.gitkeep b/jobs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/main.py b/main.py index 07b91e4..004b90d 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Instagram HDR Assembler — Web Front +Instameex - Web Front ------------------------------------ Flask front end for assembling Instagram-compliant HDR JPEGs (gain map injection adapted from kostis-kounadis/instagram-hdr-assembler — see @@ -8,18 +8,21 @@ 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 +This file is just the entry point — see src/script/app/ for the app itself: + src/script/app/config.py constants + src/script/app/jobs.py in-memory job registry + heartbeat/reaper + src/script/app/validation.py upload + Instagram resolution checks + src/script/app/assembler.py gain-map assembly pipeline + src/script/app/routes.py Flask routes Run directly: python3 main.py Run in Docker: see Dockerfile (gunicorn main:app) """ import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "src" / "script")) from app import create_app from app.assembler import check_dependencies diff --git a/screenshots/insta-hdr.png b/screenshots/insta-hdr.png deleted file mode 100644 index 9e1448e..0000000 Binary files a/screenshots/insta-hdr.png and /dev/null differ diff --git a/static/style.css b/src/assets/css/style.css similarity index 53% rename from static/style.css rename to src/assets/css/style.css index b812a1b..8dc502b 100644 --- a/static/style.css +++ b/src/assets/css/style.css @@ -1,17 +1,25 @@ :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; + --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: -apple-system, "Segoe UI", system-ui, sans-serif; + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } * { box-sizing: border-box; } @@ -20,8 +28,6 @@ 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; @@ -34,19 +40,28 @@ body { } .eyebrow { - font-family: var(--mono); - font-size: 0.72rem; - letter-spacing: 0.14em; + 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; } +.logo { + display: block; + width: 290px; + max-width: 100%; + height: auto; + margin: 0 0 1.5rem; +} + h1 { - font-family: var(--serif); - font-weight: 500; - font-size: 2.1rem; - line-height: 1.15; + font-family: var(--sans); + font-weight: 600; + font-size: 1.7rem; + line-height: 1.2; margin: 0 0 0.5rem; color: var(--text); } @@ -54,14 +69,15 @@ h1 { .subtitle { color: var(--text-muted); font-size: 0.95rem; - margin: 0 0 2.5rem; + margin: 0 0 2rem; max-width: 46ch; } .card { background: var(--surface); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); padding: 1.75rem; } @@ -91,32 +107,45 @@ form { display: block; } width: 1.5rem; height: 1.5rem; line-height: 1; - border: 1px solid var(--border); + border: 1px solid var(--border-solid); border-radius: 50%; - background: var(--surface-raised); + 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 { color: var(--err); border-color: var(--err); } +.remove-duo:hover { background: var(--err-dim); color: var(--text); } .add-duo { display: block; width: 100%; background: transparent; - border: 1px dashed var(--border); - color: var(--text-muted); + border: 1px solid var(--border-solid); + color: var(--text); padding: 0.6rem 1rem; - border-radius: 7px; + border-radius: 30px; font-size: 0.85rem; cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; + transition: background 0.2s ease; } -.add-duo:hover { color: var(--text); border-color: var(--accent-dim); } +.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; @@ -131,7 +160,7 @@ form { display: block; } .dropzone { position: relative; - border: 1px dashed var(--border); + border: 1px dashed var(--border-solid); border-radius: 8px; padding: 1.25rem 1rem; text-align: center; @@ -140,20 +169,21 @@ form { display: block; } } .dropzone:has(input:focus-visible) { - outline: 2px solid var(--accent); - outline-offset: 2px; + border-color: var(--accent); + background: var(--surface-focus); } .dropzone.has-file { - border-color: var(--accent-dim); border-style: solid; + border-color: var(--accent); } -.dropzone label.tag { +.dropzone .tag { display: block; - font-family: var(--mono); - font-size: 0.68rem; - letter-spacing: 0.1em; + 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; @@ -185,26 +215,27 @@ form { display: block; } button.run { width: 100%; margin-top: 1.5rem; - background: var(--accent); - color: #06211d; + background: linear-gradient(135deg, var(--accent-start), var(--accent-end)); + color: #fff; border: none; - padding: 0.75rem 1rem; - border-radius: 7px; + padding: 0.8rem 1rem; + border-radius: 30px; font-size: 0.95rem; - font-weight: 600; + font-weight: 700; cursor: pointer; - transition: background 0.15s ease; + box-shadow: 0 4px 16px rgba(38, 196, 255, 0.15); + transition: background 0.2s ease; } -button.run:hover { background: #5eead4; } +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(196, 96, 74, 0.12); - border: 1px solid rgba(196, 96, 74, 0.4); - color: #e2a795; + 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: 7px; + border-radius: 8px; font-size: 0.85rem; margin-bottom: 1.25rem; } @@ -213,36 +244,36 @@ button.run:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; display: inline-flex; align-items: center; gap: 0.5rem; - font-family: var(--mono); + font-family: var(--sans); + font-weight: 700; font-size: 0.8rem; - letter-spacing: 0.05em; - text-transform: uppercase; - padding: 0.35rem 0.7rem; - border-radius: 5px; + letter-spacing: 0.02em; + padding: 0.35rem 0.9rem; + border-radius: 30px; 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); } +.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(45, 212, 191, 0.1); - border: 1px solid rgba(45, 212, 191, 0.35); - color: var(--accent); + 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: 7px; + border-radius: 8px; font-size: 0.85rem; margin-bottom: 1.25rem; } pre.log { - background: #0e1015; + background: #0a0a0a; border: 1px solid var(--border); - border-radius: 7px; + border-radius: 8px; padding: 1rem; font-family: var(--mono); font-size: 0.76rem; - color: #b6c0c8; + color: #c7c7c7; overflow-x: auto; white-space: pre-wrap; word-break: break-word; @@ -261,24 +292,24 @@ pre.log { .btn-secondary { display: inline-block; text-decoration: none; - padding: 0.65rem 1.1rem; - border-radius: 7px; + padding: 0.65rem 1.3rem; + border-radius: 30px; font-size: 0.88rem; - font-weight: 600; + font-weight: 700; } .btn-download { - background: var(--accent); - color: #06211d; + background: linear-gradient(135deg, var(--accent-start), var(--accent-end)); + color: #fff; } -.btn-download:hover { background: #5eead4; } +.btn-download:hover { background: linear-gradient(135deg, var(--accent-hover-start), var(--accent-hover-end)); } .btn-secondary { background: transparent; - color: var(--text-muted); - border: 1px solid var(--border); + color: var(--text); + border: 1px solid var(--border-solid); } -.btn-secondary:hover { color: var(--text); border-color: var(--accent-dim); } +.btn-secondary:hover { background: #2d2d2d; } .footnote { margin-top: 2rem; @@ -290,6 +321,7 @@ pre.log { .footnote code { font-family: var(--mono); background: var(--surface-raised); + color: var(--accent); padding: 0.1rem 0.35rem; border-radius: 4px; } @@ -300,4 +332,30 @@ pre.log { font-family: var(--mono); font-size: 0.7rem; opacity: 0.6; -} \ No newline at end of file +} + +.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%; } diff --git a/src/assets/img/gitea.svg b/src/assets/img/gitea.svg new file mode 100644 index 0000000..ab2c40a --- /dev/null +++ b/src/assets/img/gitea.svg @@ -0,0 +1,2 @@ + +Gitea icon diff --git a/src/assets/img/github.svg b/src/assets/img/github.svg new file mode 100644 index 0000000..8202e01 --- /dev/null +++ b/src/assets/img/github.svg @@ -0,0 +1,19 @@ + + + + + github [#142] + Created with Sketch. + + + + + + + + + + + + + diff --git a/src/assets/img/logo-long.svg b/src/assets/img/logo-long.svg new file mode 100644 index 0000000..16c667d --- /dev/null +++ b/src/assets/img/logo-long.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/img/logo.svg b/src/assets/img/logo.svg new file mode 100644 index 0000000..4181b5c --- /dev/null +++ b/src/assets/img/logo.svg @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/__init__.py b/src/script/app/__init__.py similarity index 73% rename from app/__init__.py rename to src/script/app/__init__.py index 121c9ee..cd0238b 100644 --- a/app/__init__.py +++ b/src/script/app/__init__.py @@ -2,15 +2,15 @@ from flask import Flask from . import jobs -from .config import BASE_DIR, MAX_CONTENT_LENGTH, VERSION +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(BASE_DIR / "templates"), - static_folder=str(BASE_DIR / "static"), + template_folder=str(TEMPLATES_DIR), + static_folder=str(ASSETS_DIR), ) app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH diff --git a/app/assembler.py b/src/script/app/assembler.py similarity index 100% rename from app/assembler.py rename to src/script/app/assembler.py diff --git a/app/config.py b/src/script/app/config.py similarity index 76% rename from app/config.py rename to src/script/app/config.py index 9d84aa5..0367f9f 100644 --- a/app/config.py +++ b/src/script/app/config.py @@ -1,9 +1,14 @@ """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() +# This file lives at /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"} diff --git a/app/jobs.py b/src/script/app/jobs.py similarity index 100% rename from app/jobs.py rename to src/script/app/jobs.py diff --git a/app/routes.py b/src/script/app/routes.py similarity index 94% rename from app/routes.py rename to src/script/app/routes.py index 743d1d0..7f41b81 100644 --- a/app/routes.py +++ b/src/script/app/routes.py @@ -8,7 +8,7 @@ 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): +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}" @@ -28,7 +28,7 @@ def _process_pair(job_dir, index, sdr_file, hdr_file): sdr_file.save(sdr_path) hdr_file.save(hdr_path) - ig_error = validation.instagram_resolution_error(sdr_path, 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) @@ -62,11 +62,13 @@ def register_routes(app): 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) + _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) ] diff --git a/app/validation.py b/src/script/app/validation.py similarity index 78% rename from app/validation.py rename to src/script/app/validation.py index b0b6613..946aaea 100644 --- a/app/validation.py +++ b/src/script/app/validation.py @@ -42,9 +42,17 @@ def probe_dimensions(path: Path) -> tuple[int, int]: raise ValueError(f"could not read image dimensions ({e})") from e -def instagram_resolution_error(sdr_path: Path, hdr_path: Path) -> str | None: +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.""" + 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) @@ -57,7 +65,7 @@ def instagram_resolution_error(sdr_path: Path, hdr_path: Path) -> str | None: "dimensions, otherwise the gain map will misalign." ) - if (sdr_w, sdr_h) not in IG_RESOLUTIONS: + 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}." diff --git a/templates/index.html b/src/templates/index.html similarity index 69% rename from templates/index.html rename to src/templates/index.html index 321a298..b4c182a 100644 --- a/templates/index.html +++ b/src/templates/index.html @@ -3,12 +3,13 @@ - Instagram HDR Assembler - + Instameex + +
-

Instagram HDR Assembler

+

Build your HDR JPEG for Instagram

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

@@ -40,18 +41,35 @@ + +

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

-

Instagram HDR Assembler v{{ app_version }}

+

Instameex v{{ app_version }}

+