Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ release from this file.
- **`scripts/photolab-flatten.py`, for browsing a library in DxO PhotoLab.**
PhotoLab shows only the top level of a folder, so a library of day folders
never appears in one grid. The script builds a flat folder of symlinks to
every image in the tree, which PhotoLab follows. It is in the repository, not
the app, and never writes into the library. See the README.
every image in the tree, which PhotoLab follows; `--skip-paired-jpegs` shows
a RAW+JPEG shot once, as its RAW. It is in the repository, not the app, and
never writes into the library. See the README.

## [0.5.1] — 2026-09-24

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,9 @@ scripts/photolab-flatten.py ~/Pictures/Library ~/Pictures/PhotoLab-All
Link names carry the relative path, so they stay unique and sort by date
(`2026/2026-05-28/IMG_0001.CR3` → `2026__2026-05-28__IMG_0001.CR3`). Re-running adds new shots and keeps
existing links; `--prune` removes links whose original is gone, `-n` shows what would change, and
`--ext cr3 --ext jpg` narrows the formats. It never touches a real file in the destination and never
writes into the library.
`--ext cr3 --ext jpg` narrows the formats. If you shoot RAW+JPEG, `--skip-paired-jpegs` leaves out each
camera JPEG that has a RAW of the same name in the same folder, so every shot appears once. It never
touches a real file in the destination and never writes into the library.

Where PhotoLab writes its `.dop` sidecar for an image opened through a link — beside the link or beside
the original — hasn't been verified yet. Check that on a few shots before editing through the flat
Expand Down
38 changes: 28 additions & 10 deletions scripts/photolab-flatten.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,31 @@
Re-running is safe: existing correct links are kept, and with --prune, links
whose target has disappeared are removed. Real files in the destination are
never touched.

For RAW+JPEG shooting, --skip-paired-jpegs leaves out each camera JPEG that
has a RAW of the same name in the same folder, so every shot appears once.
"""
import argparse
import os
import sys
from pathlib import Path

IMAGE_EXTS = {
# rendered
"jpg", "jpeg", "jpe", "tif", "tiff", "heic", "heif", "png",
# raw
JPEG_EXTS = {"jpg", "jpeg", "jpe"}
RAW_EXTS = {
"dng", "arw", "srf", "sr2", "cr2", "cr3", "crw", "nef", "nrw", "orf",
"raf", "rw2", "rwl", "pef", "srw", "3fr", "fff", "iiq", "erf", "mef",
"mos", "mrw", "x3f", "gpr",
}
IMAGE_EXTS = JPEG_EXTS | {"tif", "tiff", "heic", "heif", "png"} | RAW_EXTS
SEP = "__"


def iter_images(root: Path, exts: set[str], dest: Path):
def split_ext(name: str) -> tuple[str, str]:
stem, _, ext = name.rpartition(".")
return stem.lower(), ext.lower()


def iter_images(root: Path, exts: set[str], dest: Path, skip_paired: bool, stats: dict):
for dirpath, dirnames, filenames in os.walk(root):
here = Path(dirpath)
# Match PhotoLab: skip hidden entries and package contents; never recurse into dest.
Expand All @@ -39,12 +46,19 @@ def iter_images(root: Path, exts: set[str], dest: Path):
and not d.endswith((".app", ".photoslibrary", ".bundle"))
and (here / d).resolve() != dest
)
# A camera JPEG is "paired" when a RAW with the same stem sits in the same folder.
raw_stems = {split_ext(f)[0] for f in filenames if split_ext(f)[1] in RAW_EXTS} if skip_paired else set()
for name in sorted(filenames):
# Skip links too, so an earlier flat folder inside the tree isn't re-linked.
if name.startswith(".") or (here / name).is_symlink():
if name.startswith(".") or "." not in name or (here / name).is_symlink():
continue
stem, ext = split_ext(name)
if ext not in exts:
continue
if ext in JPEG_EXTS and stem in raw_stems:
stats["paired"] += 1
continue
if name.rsplit(".", 1)[-1].lower() in exts and "." in name:
yield here / name
yield here / name


def link_name(src: Path, root: Path) -> str:
Expand All @@ -59,6 +73,8 @@ def main() -> int:
ap.add_argument("--prune", action="store_true", help="remove links in dest whose target no longer exists")
ap.add_argument("--ext", action="append", metavar="EXT",
help="only link these extensions (repeatable), e.g. --ext cr3 --ext jpg")
ap.add_argument("--skip-paired-jpegs", action="store_true",
help="leave out a JPEG when a RAW of the same name is beside it (RAW+JPEG shooting)")
args = ap.parse_args()

root = args.source.expanduser().resolve()
Expand All @@ -74,8 +90,9 @@ def main() -> int:

created = kept = skipped = pruned = 0
wanted = set()
stats = {"paired": 0}

for src in iter_images(root, exts, dest):
for src in iter_images(root, exts, dest, args.skip_paired_jpegs, stats):
name = link_name(src, root)
wanted.add(name)
link = dest / name
Expand Down Expand Up @@ -104,7 +121,8 @@ def main() -> int:
pruned += 1

verb = "would create" if args.dry_run else "created"
print(f"\n{verb} {created}, kept {kept}, skipped {skipped}, pruned {pruned} -> {dest}")
paired = f", left out {stats['paired']} paired JPEGs" if args.skip_paired_jpegs else ""
print(f"\n{verb} {created}, kept {kept}, skipped {skipped}, pruned {pruned}{paired} -> {dest}")
return 0


Expand Down
Loading