From e9dc1a3182a8d31155882d30ce580c8fca31f210 Mon Sep 17 00:00:00 2001
From: Josh Patra <30350506+SoPat712@users.noreply.github.com>
Date: Mon, 22 Sep 2025 15:37:23 -0400
Subject: [PATCH] Working/entirely rewritten with parallelization, a web ui for
conversation matching, and easier usage, with more commands on top of all of
that!
---
LICENSE | 2 +-
README.md | 112 ++-
bereal_exporter.py | 1939 +++++++++++++++++++++++++++++++++-----------
requirements.txt | 10 +-
4 files changed, 1568 insertions(+), 495 deletions(-)
diff --git a/LICENSE b/LICENSE
index d2b2975..0f90083 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Lukullul
+Copyright (c) 2024 SoPat712
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 05773c9..bb2bae5 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,15 @@
# BeReal Exporter
-This python script doesn't export photos and realmojis from the social media platform BeReal directly for that, you have to make a request to the BeReal see [this Reddit post](https://www.reddit.com/r/bereal_app/comments/19dl0yk/experiencetutorial_for_exporting_all_bereal/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) for more information.
+This python script doesn't export photos and realmojis from the social media platform BeReal directly - for that, you have to make a request to BeReal. See [this Reddit post](https://www.reddit.com/r/bereal_app/comments/19dl0yk/experiencetutorial_for_exporting_all_bereal/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) for more information.
-It simple processes the data from the BeReal export and exports the images(as well BTS-videos) with added metadata, such as the original date and location.
+It processes the data from the BeReal export and exports the images with added metadata, such as the original date and location. Now supports posts, memories, realmojis, and conversation images with parallel processing for speed. Also has interactive modes for when you want to manually choose which camera is which for conversation images.
I'm gonna be upfront and say it's BeReal's fault the dates are wonky on the output files, idk why they chose to save the time like this:
"takenTime": "2024-12-24T01:27:16.726Z",
"berealMoment": "2024-12-23T22:39:05.327Z",
-instead of the way everyone else always does it with UNIX Epoch time, but it makes it pretty hard to find out what time the picture was taken, and to properly tag the photos with the correct time. Scroll down to arguments and see default-timezone for a little more info.
+
+instead of the way everyone else always does it with UNIX Epoch time, but it makes it pretty hard to find out what time the picture was taken, and to properly tag the photos with the correct time. The script now handles timezone conversion automatically using GPS coordinates when available, falling back to America/New_York timezone.
## Installation
@@ -25,61 +26,132 @@ instead of the way everyone else always does it with UNIX Epoch time, but it mak
3. Ensure you have `exiftool` installed on your system and set it up as a `PATH` variable. You can download it [here](https://exiftool.org/).
+4. Put your BeReal export folder in the `input` directory. The script will automatically find it.
+
## Usage
-To export your images run the script within the BeReal export folder:
+Put your BeReal export in the `input` folder and run:
```sh
python bereal_exporter.py [OPTIONS]
```
+The script automatically finds your export folder and processes everything in parallel for speed.
+
## Options
- `-v, --verbose`: Explain what is being done.
- `-t, --timespan`: Exports the given timespan.
- Valid format: `DD.MM.YYYY-DD.MM.YYYY`.
- Wildcards can be used: `DD.MM.YYYY-*`.
-- `--exiftool-path`: Set the path to the ExifTool executable (needed if it isn't on the $PATH)
- `-y, --year`: Exports the given year.
-- `-p, --out-path`: Set a custom output path (default is `./out`).
-- `--bereal-path`: Set a custom BeReal path (default `./`)
+- `-p, --out-path`: Set a custom output path (default is `./output`).
+- `--input-path`: Set the input folder path containing BeReal export (default `./input`).
+- `--exiftool-path`: Set the path to the ExifTool executable (needed if it isn't on the $PATH).
+- `--max-workers`: Maximum number of parallel workers (default 4).
- `--no-memories`: Don't export the memories.
- `--no-realmojis`: Don't export the realmojis.
-- `--no-composites`: Don't create composites with the front image overlayed on the back.
-- `--default-timezone "America/New_York"`: Set fallback timezone, since memories.json has UTC times.
-This doesn't work the greatest but I do recommend running it with whatever timezone you're in. It goes Lat/Long time finding -> Default Timezone -> UTC or whatever BeReal is providing.
+- `--no-posts`: Don't export the posts.
+- `--no-conversations`: Don't export the conversations.
+- `--conversations-only`: Export only conversations (for debugging).
+- `--interactive-conversations`: Manually choose front/back camera for conversation images.
+- `--web-ui`: Use web UI for interactive conversation selection (requires `--interactive-conversations`).
+
+The script automatically handles timezone conversion using GPS coordinates when available, falling back to America/New_York. It creates composite images with the back camera as the main image and front camera overlaid in the corner with rounded edges and a black border, just like BeReal shows them.
## Examples
-1. Export data for the year 2022:
+1. Export everything (default behavior):
+ ```sh
+ python bereal_exporter.py
+ ```
+
+2. Export data for the year 2022:
```sh
python bereal_exporter.py --year 2022
```
-2. Export data for a specific timespan:
+3. Export data for a specific timespan:
```sh
python bereal_exporter.py --timespan '04.01.2022-31.12.2022'
```
-3. Export data to a custom output path:
+4. Export to a custom output path:
```sh
- python bereal_exporter.py --path /path/to/output
+ python bereal_exporter.py --out-path /path/to/output
```
-4. Specify the BeReal export folder:
+5. Use a different input folder:
```sh
- python bereal_exporter.py --bereal-path /path/to/export
+ python bereal_exporter.py --input-path /path/to/bereal/export
```
-4. Use portable installed exiftool application:
+6. Use portable exiftool:
```sh
python bereal_exporter.py --exiftool-path /path/to/exiftool.exe
```
-5. Export memories only:
+7. Export only memories and posts (skip realmojis and conversations):
```sh
- python bereal_exporter.py --no-realmojis
+ python bereal_exporter.py --no-realmojis --no-conversations
```
+8. Debug conversations only:
+ ```sh
+ python bereal_exporter.py --conversations-only
+ ```
+
+9. Use more workers for faster processing:
+ ```sh
+ python bereal_exporter.py --max-workers 8
+ ```
+
+10. Interactive conversation selection (command line):
+ ```sh
+ python bereal_exporter.py --conversations-only --interactive-conversations
+ ```
+
+11. Interactive conversation selection (web UI):
+ ```sh
+ python bereal_exporter.py --conversations-only --interactive-conversations --web-ui
+ ```
+
+## Interactive Conversation Processing
+
+For conversation images, the script tries to automatically detect which image should be the main view vs selfie view, but sometimes it gets it wrong. That's where the interactive modes come in handy.
+
+**Automatic Detection**: The script looks at filenames, image dimensions, and patterns to guess which camera is which. Works most of the time but not always.
+
+**Interactive Mode**: You can manually choose which image should be the selfie view (front camera overlay):
+- **Command Line** (`--interactive-conversations`): Opens images in your system viewer, you choose via keyboard
+- **Web UI** (`--interactive-conversations --web-ui`): Opens a web page where you just click on the selfie image
+
+The web UI is pretty nice - shows both images side by side, you click the one that should be the selfie view, and it automatically continues processing. Much easier than the command line version.
+
+**File Naming**: All images get descriptive names so you know what's what:
+- `2022-09-10_16-35-30_main-view.webp` (back camera)
+- `2022-09-10_16-35-30_selfie-view.webp` (front camera)
+- `2022-09-10_16-35-30_composited.webp` (combined image with selfie overlaid)
+
+## What Gets Exported
+
+The script exports different types of content to organized folders:
+
+- **Posts**: Your daily BeReal posts (main-view/selfie-view images + composited versions)
+- **Memories**: Same as posts but with richer metadata (location, multiple timestamps)
+- **Realmojis**: Your reaction images
+- **Conversations**: Images from private conversations
+
+All images get proper EXIF metadata with:
+- Original timestamps (converted to local timezone using GPS when available)
+- GPS coordinates (when available)
+- Composited images with front camera overlaid on back camera (BeReal style with rounded corners and black border)
+
+The script automatically detects duplicate content between posts and memories to avoid saving the same image twice.
+
+## Performance
+
+Uses parallel processing with configurable worker threads (default 4) for faster exports. Progress bars show real-time status. On a decent machine, expect to process hundreds of images per minute. If you have a fast SSD and good CPU, try bumping up `--max-workers` to 8 or more.
+
## License
-This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
+This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
\ No newline at end of file
diff --git a/bereal_exporter.py b/bereal_exporter.py
index d53b895..2ce9616 100644
--- a/bereal_exporter.py
+++ b/bereal_exporter.py
@@ -1,17 +1,19 @@
import argparse
-import curses
import json
import os
-import sys
+import glob
from datetime import datetime as dt
-from datetime import timezone
from shutil import copy2 as cp
-from typing import Optional
-
-import pytz
-from exiftool import ExifToolHelper as et
from PIL import Image, ImageDraw
+import pytz
from timezonefinder import TimezoneFinder
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from functools import partial
+from tqdm import tqdm
+from tqdm.contrib.logging import logging_redirect_tqdm
+import logging
+
+from exiftool import ExifToolHelper as et
def init_parser() -> argparse.Namespace:
@@ -20,566 +22,1565 @@ def init_parser() -> argparse.Namespace:
"""
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument(
- "-v", "--verbose", action="store_true", help="Explain what is being done."
+ "-v",
+ "--verbose",
+ default=False,
+ action="store_true",
+ help="Explain what is being done",
)
parser.add_argument(
- "--exiftool-path", dest="exiftool_path", help="Path to ExifTool executable."
+ "--exiftool-path",
+ dest="exiftool_path",
+ type=str,
+ help="Set the path to the ExifTool executable (needed if it isn't on the $PATH)",
)
parser.add_argument(
"-t",
"--timespan",
type=str,
- help="DD.MM.YYYY-DD.MM.YYYY or wildcards with '*'.",
+ help="Exports the given timespan\n"
+ "Valid format: 'DD.MM.YYYY-DD.MM.YYYY'\n"
+ "Wildcards can be used: 'DD.MM.YYYY-*'",
)
- parser.add_argument("-y", "--year", type=int, help="Exports the given year.")
+ parser.add_argument("-y", "--year", type=int, help="Exports the given year")
parser.add_argument(
"-p",
"--out-path",
dest="out_path",
- default="./out",
- help="Export output path (default ./out).",
+ type=str,
+ default="./output",
+ help="Set a custom output path (default ./output)",
)
parser.add_argument(
- "--bereal-path",
- dest="bereal_path",
- default=".",
- help="Path to BeReal data (default ./).",
+ "--input-path",
+ dest="input_path",
+ type=str,
+ default="./input",
+ help="Set the input folder path containing BeReal export (default ./input)",
+ )
+ parser.add_argument(
+ "--max-workers",
+ dest="max_workers",
+ type=int,
+ default=4,
+ help="Maximum number of parallel workers (default 4)",
)
parser.add_argument(
"--no-memories",
dest="memories",
default=True,
action="store_false",
- help="Don't export memories.",
+ help="Don't export the memories",
)
parser.add_argument(
"--no-realmojis",
dest="realmojis",
default=True,
action="store_false",
- help="Don't export realmojis.",
+ help="Don't export the realmojis",
)
parser.add_argument(
- "--no-composites",
- dest="composites",
+ "--no-posts",
+ dest="posts",
default=True,
action="store_false",
- help="Don't create a composite image front-on-back for each memory.",
+ help="Don't export the posts",
)
parser.add_argument(
- "--default-timezone",
- dest="default_tz",
- type=str,
- default=None,
- help="If no lat/lon or time zone lookup fails, fall back to this time zone (e.g. 'America/New_York').",
+ "--no-conversations",
+ dest="conversations",
+ default=True,
+ action="store_false",
+ help="Don't export the conversations",
+ )
+ parser.add_argument(
+ "--conversations-only",
+ dest="conversations_only",
+ default=False,
+ action="store_true",
+ help="Export only conversations (for debugging)",
+ )
+ parser.add_argument(
+ "--interactive-conversations",
+ dest="interactive_conversations",
+ default=False,
+ action="store_true",
+ help="Manually choose front/back camera for conversation images",
+ )
+ parser.add_argument(
+ "--web-ui",
+ dest="web_ui",
+ default=False,
+ action="store_true",
+ help="Use web UI for interactive conversation selection",
)
- return parser.parse_args()
-
-class CursesLogger:
- """
- When verbose is True and curses is available, we keep a multi-line log above
- and a pinned progress bar at the bottom. This might restart if the window is resized too small.
- """
-
- def __init__(self, stdscr):
- self.stdscr = stdscr
- curses.curs_set(0) # hide cursor
-
- self.max_y, self.max_x = self.stdscr.getmaxyx()
- self.log_height = self.max_y - 2 # keep bottom line(s) for the progress bar
-
- # create log window
- self.logwin = curses.newwin(self.log_height, self.max_x, 0, 0)
- self.logwin.scrollok(True)
-
- # create progress bar window
- self.pbwin = curses.newwin(1, self.max_x, self.log_height, 0)
-
- self.log_count = 0
-
- def print_log(self, text: str, force: bool = False):
- # Force doesn't matter in curses; we always show
- self.logwin.addstr(self.log_count, 0, text)
- self.logwin.clrtoeol()
- self.log_count += 1
- if self.log_count >= self.log_height:
- self.logwin.scroll(1)
- self.log_count -= 1
- self.logwin.refresh()
-
- def show_progress(self, iteration: int, total: int, prefix="", date_str=""):
- if total == 0:
- percent = 100
- else:
- percent = int(100 * iteration / total)
-
- bar_length = self.max_x - 30
- if bar_length < 10:
- bar_length = 10
-
- filled_len = bar_length * iteration // max(1, total)
- bar = "█" * filled_len + "-" * (bar_length - filled_len)
-
- line_str = f"{prefix} |{bar}| {percent}% - {date_str}"
- self.pbwin.clear()
- # Clip if the line is longer than the terminal
- self.pbwin.addstr(0, 0, line_str[: self.max_x - 1])
- self.pbwin.refresh()
-
-
-class BasicLogger:
- """
- A fallback / minimal logger if curses fails or if verbose isn't set.
- """
-
- def __init__(self, verbose: bool):
- self.verbose = verbose
-
- def print_log(self, text: str, force: bool = False):
- if self.verbose or force:
- print(text)
-
- def show_progress(self, iteration: int, total: int, prefix="", date_str=""):
- # Overwrites one line with a simple bar
- if total == 0:
- percent = 100
- else:
- percent = int(100 * iteration / total)
-
- bar_length = 40
- filled_len = bar_length * iteration // max(1, total)
- bar = "=" * filled_len + "-" * (bar_length - filled_len)
-
- line_str = f"{prefix} |{bar}| {percent}% - {date_str}"
- sys.stdout.write("\r" + line_str)
- sys.stdout.flush()
-
- if iteration == total:
- print() # newline after finishing
+ args = parser.parse_args()
+ if args.year and args.timespan:
+ print("Timespan argument will be prioritized")
+
+ # Handle conversations-only flag
+ if args.conversations_only:
+ args.memories = False
+ args.posts = False
+ args.realmojis = False
+ args.conversations = True
+ print("Running in conversations-only mode for debugging")
+
+ return args
class BeRealExporter:
- """
- Main exporter logic, with curses or fallback for logs.
- Using timezone_at only (not closest_timezone_at).
- """
-
- def __init__(self, args: argparse.Namespace, logger):
- self.args = args
- self.logger = logger
- self.verbose = args.verbose
+ def __init__(self, args: argparse.Namespace):
+ self.time_span = self.init_time_span(args)
self.exiftool_path = args.exiftool_path
self.out_path = args.out_path.rstrip("/")
- self.bereal_path = args.bereal_path.rstrip("/")
- self.create_composites = args.composites
- self.default_tz = args.default_tz
+ self.input_path = args.input_path.rstrip("/")
+ self.verbose = args.verbose
+ self.max_workers = args.max_workers
+ self.interactive_conversations = args.interactive_conversations
+ self.web_ui = args.web_ui
+
+ # Setup logging for clean progress bars
+ if self.verbose:
+ logging.basicConfig(level=logging.INFO, format='%(message)s')
+ self.logger = logging.getLogger(__name__)
+ else:
+ self.logger = None
+
+ # Find the BeReal export folder inside input
+ self.bereal_path = self.find_bereal_export_folder()
- # parse timespan/year
- self.time_span = self.init_time_span(args)
-
- # For lat/lon lookups
- self.tf = TimezoneFinder()
-
- def init_time_span(self, args: argparse.Namespace) -> tuple:
+ @staticmethod
+ def init_time_span(args: argparse.Namespace) -> tuple:
+ """
+ Initializes time span based on the arguments.
+ """
if args.timespan:
try:
start_str, end_str = args.timespan.strip().split("-")
- if start_str == "*":
- start = dt(1970, 1, 1, tzinfo=timezone.utc)
- else:
- naive_start = dt.strptime(start_str, "%d.%m.%Y")
- start = naive_start.replace(tzinfo=timezone.utc)
- if end_str == "*":
- end = dt.now(tz=timezone.utc)
- else:
- naive_end = dt.strptime(end_str, "%d.%m.%Y")
- naive_end = naive_end.replace(hour=23, minute=59, second=59)
- end = naive_end.replace(tzinfo=timezone.utc)
+ start = (
+ dt.fromtimestamp(0)
+ if start_str == "*"
+ else dt.strptime(start_str, "%d.%m.%Y")
+ )
+ end = dt.now() if end_str == "*" else dt.strptime(end_str, "%d.%m.%Y")
return start, end
except ValueError:
raise ValueError(
- "Invalid timespan format. Use 'DD.MM.YYYY-DD.MM.YYYY' or '*' wildcard."
+ "Invalid timespan format. Use 'DD.MM.YYYY-DD.MM.YYYY'."
)
elif args.year:
- naive_start = dt(args.year, 1, 1)
- naive_end = dt(args.year, 12, 31, 23, 59, 59)
- return (
- naive_start.replace(tzinfo=timezone.utc),
- naive_end.replace(tzinfo=timezone.utc),
- )
+ return dt(args.year, 1, 1), dt(args.year, 12, 31)
else:
- return (
- dt(1970, 1, 1, tzinfo=timezone.utc),
- dt.now(tz=timezone.utc),
- )
+ return dt.fromtimestamp(0), dt.now()
+
+ def find_bereal_export_folder(self) -> str:
+ """
+ Finds the BeReal export folder inside the input directory.
+ """
+ if not os.path.exists(self.input_path):
+ raise FileNotFoundError(f"Input path not found: {self.input_path}")
+
+ # Look for folders that contain the expected structure
+ for item in os.listdir(self.input_path):
+ item_path = os.path.join(self.input_path, item)
+ if os.path.isdir(item_path):
+ # Check if this folder contains the expected JSON files
+ if (os.path.exists(os.path.join(item_path, "memories.json")) or
+ os.path.exists(os.path.join(item_path, "posts.json"))):
+ return item_path
+
+ raise FileNotFoundError("No BeReal export folder found in input directory")
def verbose_msg(self, msg: str):
- if self.verbose:
- self.logger.print_log(msg)
-
- def log(self, text: str, force: bool = False):
- self.logger.print_log(text, force=force)
-
- def show_progress(self, i: int, total: int, prefix="", date_str=""):
- self.logger.show_progress(i, total, prefix, date_str)
-
- def resolve_img_path(self, path_str: str) -> Optional[str]:
- if "/post/" in path_str:
- candidate = os.path.join(
- self.bereal_path, "Photos/post", os.path.basename(path_str)
- )
- if os.path.isfile(candidate):
- return candidate
- elif "/bereal/" in path_str:
- candidate = os.path.join(
- self.bereal_path, "Photos/bereal", os.path.basename(path_str)
- )
- if os.path.isfile(candidate):
- return candidate
-
- # fallback
- p1 = os.path.join(self.bereal_path, "Photos/post", os.path.basename(path_str))
- p2 = os.path.join(self.bereal_path, "Photos/bereal", os.path.basename(path_str))
- if os.path.isfile(p1):
- return p1
- if os.path.isfile(p2):
- return p2
- return None
-
- def localize_datetime(self, dt_utc: dt, lat: float, lon: float) -> dt:
"""
- Use tf.timezone_at(...) only. If lat/lon missing or fails, fallback to default tz or stay UTC.
+ Prints an explanation of what is being done to the terminal.
+ Uses logging to work nicely with progress bars.
"""
- if lat is None or lon is None:
- # fallback
- if self.default_tz:
- try:
- fallback_zone = pytz.timezone(self.default_tz)
- return dt_utc.astimezone(fallback_zone)
- except Exception as e:
- self.verbose_msg(
- f"Warning: fallback time zone '{self.default_tz}' invalid: {e}"
- )
- return dt_utc
+ if self.verbose and self.logger:
+ self.logger.info(msg)
+ def convert_to_local_time(self, utc_dt: dt, location=None) -> dt:
+ """
+ Converts UTC datetime to local timezone based on location or defaults to America/New_York.
+ """
+ # Ensure the datetime is timezone-aware (UTC)
+ if utc_dt.tzinfo is None:
+ utc_dt = pytz.UTC.localize(utc_dt)
+ elif utc_dt.tzinfo != pytz.UTC:
+ utc_dt = utc_dt.astimezone(pytz.UTC)
+
+ # Default timezone
+ local_tz = pytz.timezone('America/New_York')
+
+ # Try to get timezone from location if available
+ if location and "latitude" in location and "longitude" in location:
+ try:
+ tf = TimezoneFinder()
+ timezone_str = tf.timezone_at(
+ lat=location["latitude"],
+ lng=location["longitude"]
+ )
+ if timezone_str:
+ local_tz = pytz.timezone(timezone_str)
+ self.verbose_msg(f"Using timezone {timezone_str} from GPS location")
+ else:
+ self.verbose_msg("GPS location found but timezone lookup failed, using America/New_York")
+ except Exception as e:
+ self.verbose_msg(f"Error determining timezone from GPS: {e}, using America/New_York")
+ else:
+ self.verbose_msg("No GPS location, using America/New_York timezone")
+
+ # Convert to local time and return naive datetime for EXIF
+ local_dt = utc_dt.astimezone(local_tz)
+ return local_dt.replace(tzinfo=None)
+
+ def process_memory(self, memory, out_path_memories):
+ """
+ Processes a single memory (for parallel execution).
+ Saves to posts folder and skips if files already exist to avoid duplicates.
+ """
+ memory_dt = self.get_datetime_from_str(memory["takenTime"])
+ if not (self.time_span[0] <= memory_dt <= self.time_span[1]):
+ return None
+
+ # Get front and back image paths
+ front_path = os.path.join(self.bereal_path, memory["frontImage"]["path"])
+ back_path = os.path.join(self.bereal_path, memory["backImage"]["path"])
+
+ # Convert to local time for filename (to match EXIF metadata)
+ img_location = memory.get("location", None)
+ local_dt = self.convert_to_local_time(memory_dt, img_location)
+
+ # Create output filenames with descriptive names
+ base_filename = f"{local_dt.strftime('%Y-%m-%d_%H-%M-%S')}"
+ secondary_output = f"{out_path_memories}/{base_filename}_selfie-view.webp" # front camera
+ primary_output = f"{out_path_memories}/{base_filename}_main-view.webp" # back camera
+ composite_output = f"{out_path_memories}/{base_filename}_composited.webp"
+
+ # Skip if files already exist (avoid duplicates from posts)
+ if os.path.exists(primary_output) and os.path.exists(secondary_output) and os.path.exists(composite_output):
+ self.verbose_msg(f"Skipping {base_filename} - already exists from posts export")
+ return f"{base_filename} (skipped - duplicate)"
+
+ # Export individual images (front=secondary, back=primary)
+ if not os.path.exists(secondary_output):
+ self.export_img(front_path, secondary_output, memory_dt, img_location)
+ if not os.path.exists(primary_output):
+ self.export_img(back_path, primary_output, memory_dt, img_location)
+
+ # Create composite image (back/primary as background, front/secondary as overlay - BeReal style)
+ if not os.path.exists(composite_output) and os.path.exists(secondary_output) and os.path.exists(primary_output):
+ self.create_composite_image(primary_output, secondary_output, composite_output, memory_dt, img_location)
+
+ return base_filename
+
+ def process_post(self, post, out_path_posts):
+ """
+ Processes a single post (for parallel execution).
+ """
+ post_dt = self.get_datetime_from_str(post["takenAt"])
+ if not (self.time_span[0] <= post_dt <= self.time_span[1]):
+ return None
+
+ # Get primary and secondary image paths
+ primary_path = os.path.join(self.bereal_path, post["primary"]["path"])
+ secondary_path = os.path.join(self.bereal_path, post["secondary"]["path"])
+
+ # Convert to local time for filename (to match EXIF metadata)
+ post_location = post.get("location", None)
+ local_dt = self.convert_to_local_time(post_dt, post_location)
+
+ # Create output filename
+ base_filename = f"{local_dt.strftime('%Y-%m-%d_%H-%M-%S')}"
+
+ # Export individual images
+ primary_output = f"{out_path_posts}/{base_filename}_main-view.webp"
+ secondary_output = f"{out_path_posts}/{base_filename}_selfie-view.webp"
+ composite_output = f"{out_path_posts}/{base_filename}_composited.webp"
+
+
+
+ # Export primary image
+ self.export_img(primary_path, primary_output, post_dt, post_location)
+
+ # Export secondary image
+ self.export_img(secondary_path, secondary_output, post_dt, post_location)
+
+ # Create composite image
+ if os.path.exists(primary_output) and os.path.exists(secondary_output):
+ self.create_composite_image(primary_output, secondary_output, composite_output, post_dt, post_location)
+
+ return base_filename
+
+ def interactive_choose_primary_overlay(self, original_files, exported_files, conversation_id, file_id, progress_info=None):
+ """
+ Interactive mode to let user choose which image is main view vs selfie view.
+ Opens images in system viewer for preview.
+ """
+ if len(exported_files) != 2:
+ return exported_files[0], exported_files[1] if len(exported_files) > 1 else exported_files[0]
+
+ print(f"\n--- Conversation {conversation_id}, Message ID {file_id} ---")
+ if progress_info:
+ print(f"Progress: {progress_info}")
+
+ # Show image info
try:
- tz_name = self.tf.timezone_at(lng=lon, lat=lat)
- if tz_name:
- local_zone = pytz.timezone(tz_name)
- return dt_utc.astimezone(local_zone)
- else:
- # fallback
- if self.default_tz:
- try:
- fallback_zone = pytz.timezone(self.default_tz)
- return dt_utc.astimezone(fallback_zone)
- except Exception as e:
- self.verbose_msg(
- f"Warning: fallback time zone '{self.default_tz}' invalid: {e}"
- )
- return dt_utc
+ from PIL import Image
+ img1 = Image.open(exported_files[0])
+ img2 = Image.open(exported_files[1])
+ print(f"Image 1: {os.path.basename(exported_files[0])} ({img1.width}x{img1.height}, {img1.width/img1.height:.2f} ratio)")
+ print(f"Image 2: {os.path.basename(exported_files[1])} ({img2.width}x{img2.height}, {img2.width/img2.height:.2f} ratio)")
+ img1.close()
+ img2.close()
+ except Exception:
+ print(f"Image 1: {os.path.basename(exported_files[0])}")
+ print(f"Image 2: {os.path.basename(exported_files[1])}")
+
+ # Open images in system viewer
+ print("\nOpening images in system viewer...")
+ try:
+ import subprocess
+ import platform
+
+ system = platform.system()
+ for i, img_path in enumerate(exported_files, 1):
+ print(f"Opening Image {i}...")
+ if system == "Darwin": # macOS
+ subprocess.run(["open", img_path], check=False)
+ elif system == "Windows":
+ subprocess.run(["start", img_path], shell=True, check=False)
+ else: # Linux
+ subprocess.run(["xdg-open", img_path], check=False)
+
+ # Small delay between opening images
+ import time
+ time.sleep(0.5)
+
except Exception as e:
- self.verbose_msg(
- f"Warning: Time zone lookup failed for lat={lat}, lon={lon}: {e}"
- )
- if self.default_tz:
- try:
- fallback_zone = pytz.timezone(self.default_tz)
- return dt_utc.astimezone(fallback_zone)
- except Exception as e2:
- self.verbose_msg(
- f"Warning: fallback time zone '{self.default_tz}' invalid: {e2}"
- )
- return dt_utc
+ print(f"Could not open images automatically: {e}")
+ print("Please manually open the images to view them.")
+
+ print("\nWhich image should be the SELFIE VIEW (front camera/overlay)?")
+ print("1. Image 1")
+ print("2. Image 2")
+ print("3. Skip composite creation")
+
+ while True:
+ try:
+ choice = input("Enter choice (1, 2, or 3): ").strip()
+ if choice == "1":
+ return exported_files[1], exported_files[0] # img2 main, img1 selfie
+ elif choice == "2":
+ return exported_files[0], exported_files[1] # img1 main, img2 selfie
+ elif choice == "3":
+ return None, None # Skip composite
+ else:
+ print("Please enter 1, 2, or 3")
+ except (KeyboardInterrupt, EOFError):
+ print("\nSkipping composite creation...")
+ return None, None
- def embed_exif(
- self, file_name: str, dt_utc: dt, lat: float = None, lon: float = None
+ def web_ui_choose_primary_overlay(self, exported_files, conversation_id, file_id, progress_info=None):
+ """
+ Web UI mode to let user choose which image is selfie view.
+ Creates a simple HTML page with side-by-side images.
+ """
+ if len(exported_files) != 2:
+ return exported_files[0], exported_files[1] if len(exported_files) > 1 else exported_files[0]
+
+ import tempfile
+ import webbrowser
+ import base64
+
+ # Create temporary HTML file
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f:
+ # Convert images to base64 for embedding
+ img1_b64 = ""
+ img2_b64 = ""
+ try:
+ with open(exported_files[0], 'rb') as img_file:
+ img1_b64 = base64.b64encode(img_file.read()).decode()
+ with open(exported_files[1], 'rb') as img_file:
+ img2_b64 = base64.b64encode(img_file.read()).decode()
+ except Exception as e:
+ print(f"Error reading images: {e}")
+ return self.interactive_choose_primary_overlay([], exported_files, conversation_id, file_id)
+
+ html_content = f"""
+
+
+