This is a multipart series in a blog series about using Lightroom and the Samsung Frame TV. Check out Part 1 (Lightroom configuration), Part 2 (using ChatGPT for generating an upload script) and Part 3, Lightroom Mobile integration.
It’s amazing how much can change in a year with technology. In June 2024 I wrote a blog explaining how I upload photos from Lightroom to my Samsung Frame TV. I found a Python library, downloaded a Python development environment, learned Python (with a reasonable amount of pain – but got there), and had a script that I was proud of after two or so weeks of learning and tinkering. I’ve been spending more and more time at work learning about AI. I also went to an AI meet up in the South Bay and picked up on one of the presenter’s taglines: “With AI, English is the hottest new programming language.” I was impressed how he was using AI in his presentation and wanted to revisit my Lightroom script to see if and how I could do better. In short, I could do faster and better. I tossed this challenge ChatGPT’s way to see what would happen. In short: I was blown away. Let’s dig in.
I like the way the Lightroom configuration works in the original blog. However, I want to see if ChatGPT can make the Python piece easier – especially for readers like you so you don’t have as steep of a learning curve as I did.
1. Create a ChatGPT project.
I like creating projects inside of ChatGPT so that every topical area is independent of all the other conversations I have going on inside of the platform. Click new project in the left-hand column after logging into ChatGPT. Let’s call this project “Lightroom Frame TV Uploader.”

2. Tell ChatGPT what to create
I generally liked what my Python code did in the former blog, however, it was missing two key features that I didn’t have the time or patience to develop. I wanted a progress bar so I knew what the status of the upload was and which specific files failed, if any, during the batch upload. To start, I asked ChatGPT to create that code. I used the following prompt:
I have a directory of images that I would like to upload to the Samsung Frame TV on a brand new mac:
- Help me setup python
- Synchronously upload the files.
- Show a progress bar. The progress bar shows the progress of the whole batch.
- Show the file that it's uploading and its matte color while it works.
- Once a picture is uploaded, set the matte to the value stored in the exif value ImageDescription.
- Create a counter for the number of images that upload correctly and error out.
- Show both counter totals at the end list and the file names that don't upload correctly after a retry
- Use Nick Waterton’s samsung-tv-ws-api library
3. Be Amazed
ChatGPT then replied with four key sections to help get me running:
- How to set up Python on my Mac
- The Python code to do the actual upload
- Instructions to run the Python code
- A description of what the script does
- Caveats of what I might need to adjust
That was about two weeks of my time which was solved by ChatGPT in under a few minutes. #mindBlown. What was even more impressive is that I was able to interact with ChatGPT to make the code better. There’s a section that catches typos. It’s more flexible supporting all of the different matte types. The code is more resilient to errors.
When running it for the first time, the script didn’t respect the imageDescription value set in Lightroom. I simply asked ChatGPT to regenerate the code highligting this issue. The next version worked flawlessly and is better in so many ways than my original attempt. This was one of these personal, lightbulb moments that really changed my perception of AI as a collaborative partner helping me move faster solving a meaningful “problem” in an expedient, but thoughtful way.
I’ve included the code below for reference in case your experience with ChatGPT is different than mine. If things don’t work as expected, copy the error message and ask ChatGPT what is wrong. I’ve found the help to be invaluable to troubleshoot any errors I’ve run into.
To Run: (after following ChatGPT’s python setup steps in the Mac terminal)
source ~/frame_tv/.venv/bin/activate
python frame_upload_batch.py –ip tv.ip.address –dir path-to-your-files
#!/usr/bin/env python3
import argparse
import os
import sys
import time
from pathlib import Path
from PIL import Image, ExifTags
from tqdm import tqdm
from samsungtvws import SamsungTVWS
# --- Matte dictionaries (style + color) ---
MATTE_TYPES = {
"none", "modernthin", "modern", "modernwide",
"flexible", "shadowbox", "panoramic", "triptych", "mix", "squares"
}
MATTE_COLORS = {
"black", "neutral", "antique", "warm", "polar", "sand", "seafoam", "sage",
"burgandy", "navy", "apricot", "byzantine", "lavender", "redorange",
"skyblue", "turquoise"
}
# normalizing typos / spacing
SYNONYMS = {
"burgundy": "burgandy",
"red orange": "redorange",
"red_orange": "redorange",
"red-orange": "redorange",
"sky blue": "skyblue",
}
# EXIF tag id for ImageDescription
EXIF_TAGS = {v: k for k, v in ExifTags.TAGS.items()}
IMG_DESC_TAG = EXIF_TAGS.get("ImageDescription", 270)
def read_exif_imagedescription(path: Path) -> str | None:
"""Return EXIF:ImageDescription as a clean string, if present."""
try:
with Image.open(path) as im:
exif = im.getexif()
if not exif:
return None
val = exif.get(IMG_DESC_TAG)
if isinstance(val, bytes):
try:
val = val.decode(errors="ignore")
except Exception:
val = str(val)
if val:
return str(val).strip()
except Exception:
pass
return None
def _clean_token(s: str) -> str:
s = s.strip().lower().replace("_", " ").replace("-", " ")
s = " ".join(s.split())
return SYNONYMS.get(s, s)
def parse_matte(text: str | None,
default_style: str = "flexible",
default_color: str = "warm") -> tuple[str, str] | tuple[str, None]:
"""
Parse a free-form matte description into (style, color).
Accepts:
- "none"
- "apricot" (color only -> default style + that color)
- "modern apricot" (style + color, in any spacing/case)
- "modern_apricot" or "modern-apricot"
- "style=modern color=apricot" (key/value pairs, any order)
Returns:
("none", None) OR (style, color) where both are valid.
"""
if not text:
return default_style, default_color
raw = text.strip()
# First: extract key/value pairs if present
kv_style = kv_color = None
# very forgiving parser for "style=... color=..." or "matte=..."
for part in raw.replace(",", " ").split():
if "=" in part:
k, v = part.split("=", 1)
k = _clean_token(k)
v = _clean_token(v)
if k in ("style", "matte", "matte_style"):
kv_style = v
elif k in ("color", "matte_color"):
kv_color = v
if kv_style or kv_color:
st = kv_style or default_style
co = kv_color or default_color
st = _clean_token(st)
co = _clean_token(co)
if st == "none":
return "none", None
if st not in MATTE_TYPES:
st = default_style
if co not in MATTE_COLORS:
co = default_color
return st, co
# Otherwise: tokenized words / joined tokens
tok = _clean_token(raw)
if tok == "none":
return "none", None
# combined "modern apricot" (space-joined above)
parts = tok.split()
if len(parts) == 2:
st, co = parts
if st in MATTE_TYPES and co in MATTE_COLORS:
return st, co
# combined "modernapricot" or "modern_apricot" were normalized to "modern apricot"
# handle "modernapricot" specifically:
for st in MATTE_TYPES:
if st != "none" and tok.startswith(st):
co = tok[len(st):].strip()
if co in MATTE_COLORS:
return st, co
# color only
if tok in MATTE_COLORS:
return default_style, tok
# style only
if tok in MATTE_TYPES:
if tok == "none":
return "none", None
return tok, default_color
# fallback
return default_style, default_color
def is_portrait(path: Path) -> bool:
try:
with Image.open(path) as im:
w, h = im.size
return h > w
except Exception:
return False
def file_type_for(path: Path) -> str:
ext = path.suffix.lower()
if ext == ".png":
return "PNG"
return "JPEG"
def build_matte_kwargs(style: str, color: str | None) -> dict:
"""
Convert our parsed (style, color) to samsungtvws kwargs.
Library accepts 'matte'/'portrait_matte' as strings like 'modern_apricot'
or 'none'. We’ll build the proper tokens here.
"""
if style == "none":
matte_token = "none"
else:
matte_token = f"{style}_{color}"
return {"matte": matte_token, "portrait_matte": matte_token}
def upload_one(tv: SamsungTVWS, img_path: Path, matte_kwargs: dict, delay_s: float) -> None:
with open(img_path, "rb") as f:
data = f.read()
tv.art().upload(data, file_type=file_type_for(img_path), **matte_kwargs)
if delay_s > 0:
time.sleep(delay_s)
def main():
parser = argparse.ArgumentParser(description="Batch upload to Samsung Frame; matte from EXIF ImageDescription.")
parser.add_argument("--ip", required=True, help="Frame TV IP (e.g. 192.168.1.100)")
parser.add_argument("--dir", required=True, help="Directory of images")
parser.add_argument("--token-file", default=os.path.expanduser("~/.frame_tv_token.txt"),
help="Token file for pairing (created on first allow)")
parser.add_argument("--delay", type=float, default=1.2, help="Delay (s) between uploads")
parser.add_argument("--retry", type=int, default=1, help="Retries per file on error")
args = parser.parse_args()
src = Path(args.dir).expanduser().resolve()
if not src.exists() or not src.is_dir():
print(f"Directory not found: {src}", file=sys.stderr)
sys.exit(2)
files = sorted([p for p in src.iterdir() if p.suffix.lower() in (".jpg", ".jpeg", ".png")])
if not files:
print("No .jpg/.jpeg/.png files found.")
sys.exit(0)
# Connect once (secure ws on 8002)
tv = SamsungTVWS(host=args.ip, port=8002, token_file=args.token_file)
success = 0
error = 0
failed: list[str] = []
# Single total-batch bar; filename is shown both in bar and as a line above it
pbar = tqdm(total=len(files), unit="img", ncols=100,
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]")
for idx, img in enumerate(files, start=1):
# Read EXIF → parse into (style, color)
exif_val = read_exif_imagedescription(img)
style, color = parse_matte(exif_val)
matte_kwargs = build_matte_kwargs(style, color)
# Loudly show what we're doing (safe with tqdm)
tqdm.write(f"[{idx}/{len(files)}] Uploading: {img.name} | matte: "
f"{'none' if style=='none' else f'{style}_{color}'}")
pbar.set_description("Uploading")
pbar.set_postfix(file=img.name,
matte=('none' if style == 'none' else f'{style}_{color}'),
refresh=True)
attempt = 0
last_exc = None
while attempt <= args.retry:
try:
upload_one(tv, img, matte_kwargs, delay_s=args.delay)
success += 1
break
except Exception as e:
last_exc = e
attempt += 1
time.sleep(1.0)
if attempt > args.retry:
error += 1
failed.append(f"{img.name} :: {type(last_exc).__name__}: {last_exc}")
pbar.update(1)
pbar.close()
print("n=== Batch complete ===")
print(f"Uploaded OK: {success}")
print(f"Errors: {error}")
if failed:
print("nFiles that failed after retry:")
for f in failed:
print(" -", f)
if __name__ == "__main__":
main()

Leave a Reply