Files
StarPilot/starpilot/system/the_pond/utilities.py
T
2026-03-28 18:35:57 -05:00

719 lines
24 KiB
Python

#!/usr/bin/env python3
import base64
import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import List
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.system.loggerd.config import get_available_bytes, get_used_bytes
from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE
from openpilot.system.loggerd.uploader import listdir_by_creation
from openpilot.tools.lib.route import SegmentName
from openpilot.starpilot.common.starpilot_variables import THEME_SAVE_PATH, VIDEO_CACHE_PATH
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH
LOG_CANDIDATES = [
"qlog",
"qlog.zst",
"qlog.bz2",
"rlog",
"rlog.zst",
"rlog.bz2",
"raw_log.zst",
"raw_log.bz2",
]
ROUTE_TIME_LOG_CANDIDATES = [
"rlog.zst",
"rlog.bz2",
"rlog",
"qlog.zst",
"qlog.bz2",
"qlog",
"raw_log.zst",
"raw_log.bz2",
]
SEGMENT_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}--\d+$")
TARGET_LOUDNESS = -15.0
METER_TO_MILE = 1.0 / 1609.344
XOR_KEY = "s8#pL3*Xj!aZ@dWq"
MAX_FILE_SIZE = 5 * 1024 * 1024
_FILENAME_SANITIZE_RE = re.compile(r"[^A-Za-z0-9_.-]+")
_POND_DEPS_PATH = "/data/pond_deps"
if os.path.isdir(_POND_DEPS_PATH) and _POND_DEPS_PATH not in sys.path:
sys.path.insert(0, _POND_DEPS_PATH)
_REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
if _REPO_THIRD_PARTY_PATH.is_dir() and str(_REPO_THIRD_PARTY_PATH) not in sys.path:
sys.path.insert(0, str(_REPO_THIRD_PARTY_PATH))
_PIL_IMAGE = None
_PYDUB_AUDIOSEGMENT = None
params = Params(return_defaults=True)
def secure_filename(filename):
safe = os.path.basename(str(filename or ""))
safe = safe.replace(" ", "_").strip()
safe = _FILENAME_SANITIZE_RE.sub("_", safe)
safe = safe.strip("._")
return safe or "file"
def _decode_json_param(value, default):
if value is None:
return default
if isinstance(value, (dict, list)):
return value
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
text = value.strip()
if not text:
return default
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return default
if isinstance(default, dict) and isinstance(parsed, dict):
return parsed
if isinstance(default, list) and isinstance(parsed, list):
return parsed
return default
return default
def _get_pillow_image():
global _PIL_IMAGE
if _PIL_IMAGE is None:
from PIL import Image as pil_image
_PIL_IMAGE = pil_image
return _PIL_IMAGE
def _get_pydub_audio_segment():
global _PYDUB_AUDIOSEGMENT
if _PYDUB_AUDIOSEGMENT is None:
from pydub import AudioSegment as pydub_audio_segment
_PYDUB_AUDIOSEGMENT = pydub_audio_segment
return _PYDUB_AUDIOSEGMENT
def check_theme_components(theme_path):
components = {
"hasColors": False,
"hasIcons": False,
"hasSounds": False,
"hasTurnSignals": False,
"hasDistanceIcons": False,
"hasSteeringWheel": False
}
colors_path = theme_path / "colors" / "colors.json"
if colors_path.exists():
components["hasColors"] = True
icons_path = theme_path / "icons"
if icons_path.exists() and any(icons_path.iterdir()):
components["hasIcons"] = True
sounds_path = theme_path / "sounds"
if sounds_path.exists() and any(sounds_path.iterdir()):
components["hasSounds"] = True
signals_path = theme_path / "signals"
if signals_path.exists() and any(signals_path.iterdir()):
components["hasTurnSignals"] = True
distance_icons_path = theme_path / "distance_icons"
if distance_icons_path.exists() and any(distance_icons_path.iterdir()):
components["hasDistanceIcons"] = True
is_holiday_theme = str(HOLIDAY_THEME_PATH) in str(theme_path)
if is_holiday_theme:
wheel_path = theme_path / "steering_wheel"
if wheel_path.exists() and any(f.name.startswith("wheel.") for f in wheel_path.iterdir()):
components["hasSteeringWheel"] = True
else:
wheel_path = THEME_SAVE_PATH / "steering_wheels"
if wheel_path.exists():
theme_name = theme_path.name.replace('-user_created', '')
if any(wheel_path.glob(f"{theme_name}-user_created.*")):
components["hasSteeringWheel"] = True
return components
def covert_audio(input_file):
sound = _get_pydub_audio_segment().from_file(input_file)
sound = sound.set_frame_rate(48000)
sound = sound.set_channels(1)
output_filename = os.path.splitext(input_file)[0] + ".wav"
sound.export(output_filename, format="wav", parameters=["-acodec", "pcm_s16le"])
if input_file != output_filename:
os.remove(input_file)
def create_theme(form_data, files, temporary=False):
theme_name = form_data.get("themeName")
if not theme_name:
return None, "Theme name is required."
sane_theme_name = secure_filename(theme_name.replace(" ", "_"))
save_checklist_str = form_data.get("saveChecklist", "{}")
save_checklist = json.loads(save_checklist_str)
needs_theme_pack = any([
save_checklist.get("colors"),
save_checklist.get("icons"),
save_checklist.get("sounds"),
save_checklist.get("turn_signals"),
save_checklist.get("distance_icons"),
])
if temporary:
base_path = Path(f"/tmp/{sane_theme_name}_{secrets.token_hex(8)}")
else:
base_path = THEME_SAVE_PATH / "theme_packs" if needs_theme_pack else None
theme_path = (base_path / f"{sane_theme_name}-user_created") if base_path else None
if theme_path:
theme_path.mkdir(parents=True, exist_ok=True)
if save_checklist.get("colors"):
(theme_path / "colors").mkdir(exist_ok=True)
colors_str = form_data.get("colors")
if colors_str:
color_data = json.loads(colors_str)
for key, values in color_data.items():
if "alpha" in values:
values["alpha"] = values.pop("alpha")
colors_file = theme_path / "colors" / "colors.json"
with open(colors_file, "w") as f:
json.dump(color_data, f, indent=2)
if save_checklist.get("turn_signals"):
signals_path = theme_path / "signals"
signals_path.mkdir(exist_ok=True)
if turn_signal_length := form_data.get("turnSignalLength"):
style = form_data.get("turnSignalStyle", "Traditional").lower()
(signals_path / f"{style}_{turn_signal_length}").touch()
turn_signal_type = form_data.get("turnSignalType", "Single Image").lower()
if turn_signal_type == "single image":
for f in signals_path.glob("turn_signal.*"):
f.unlink()
for f in signals_path.glob("turn_signal_blindspot.*"):
f.unlink()
file = files.get("turnSignal")
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
ext = Path(file.filename).suffix
file.save(signals_path / f"turn_signal{ext}")
file = files.get("turnSignalBlindspot")
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
ext = Path(file.filename).suffix
file.save(signals_path / f"turn_signal_blindspot{ext}")
elif turn_signal_type == "sequential":
for f in signals_path.glob("turn_signal_*"):
f.unlink()
signal_map = {
"turnSignal": "turn_signal",
"turnSignalBlindspot": "turn_signal_blindspot",
}
for field, base_name in signal_map.items():
file = files.get(field)
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
for f in signals_path.glob(f"{base_name}.*"):
f.unlink()
ext = Path(file.filename).suffix.lower()
file.save(signals_path / f"{base_name}{ext}")
for f in signals_path.glob("turn_signal.*"):
f.unlink()
for f in signals_path.glob("turn_signal_blindspot.*"):
f.unlink()
sequential_keys = sorted(
[k for k in files if k.startswith("turn_signal_")],
key=lambda name: int(name.split("_")[-1])
)
for key in sequential_keys:
file = files.get(key)
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
idx = key.split("_")[-1]
ext = Path(file.filename).suffix
file.save(signals_path / f"turn_signal_{idx}{ext}")
if save_checklist.get("icons"):
(theme_path / "icons").mkdir(exist_ok=True)
icon_map = {
"settingsButton": (theme_path / "icons", "button_settings", (169, 104)),
"homeButton": (theme_path / "icons", "button_home", (250, 250)),
}
for field, (dest_path, base_name, resize_dims) in icon_map.items():
file = files.get(field)
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
for f in dest_path.glob(f"{base_name}.*"):
f.unlink()
ext = Path(file.filename).suffix.lower()
save_path = dest_path / f"{base_name}{ext}"
file.save(save_path)
if resize_dims:
if ext == ".gif":
width, height = resize_dims
palette_path = save_path.with_suffix(".palette.png")
temp_output_path = save_path.with_suffix(".resized.gif")
subprocess.run(["ffmpeg", "-i", str(save_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
subprocess.run(["ffmpeg", "-i", str(save_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
palette_path.unlink()
temp_output_path.rename(save_path)
else:
pil_image = _get_pillow_image()
img = pil_image.open(save_path).resize(resize_dims, pil_image.Resampling.LANCZOS)
if ext != ".png":
save_path.unlink()
save_path = save_path.with_suffix(".png")
img.save(save_path, "PNG")
if save_checklist.get("steering_wheel"):
wheels_dir = THEME_SAVE_PATH / "steering_wheels"
wheels_dir.mkdir(parents=True, exist_ok=True)
file = files.get("steeringWheel")
saved_wheel_path = None
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
for f in wheels_dir.glob(f"{sane_theme_name}-user_created.*"):
f.unlink()
ext = Path(file.filename).suffix.lower()
saved_wheel_path = wheels_dir / f"{sane_theme_name}-user_created{ext}"
file.save(saved_wheel_path)
if ext == ".gif":
width, height = (250, 250)
palette_path = saved_wheel_path.with_suffix(".palette.png")
temp_output_path = saved_wheel_path.with_suffix(".resized.gif")
subprocess.run(["ffmpeg", "-i", str(saved_wheel_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
subprocess.run(["ffmpeg", "-i", str(saved_wheel_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
palette_path.unlink()
temp_output_path.rename(saved_wheel_path)
else:
pil_image = _get_pillow_image()
img = pil_image.open(saved_wheel_path).resize((250, 250), pil_image.Resampling.LANCZOS)
if ext != ".png":
saved_wheel_path.unlink()
saved_wheel_path = saved_wheel_path.with_suffix(".png")
img.save(saved_wheel_path, "PNG")
if temporary and (theme_path is not None):
existing = saved_wheel_path if saved_wheel_path is not None else next(wheels_dir.glob(f"{sane_theme_name}-user_created.*"), None)
if existing:
wheel_icon_dir = theme_path / "WheelIcon"
wheel_icon_dir.mkdir(parents=True, exist_ok=True)
dest = wheel_icon_dir / f"wheel{existing.suffix.lower()}"
if dest.exists():
dest.unlink()
dest.symlink_to(existing)
if save_checklist.get("distance_icons"):
dist_path = theme_path / "distance_icons"
dist_path.mkdir(exist_ok=True)
for name in ["traffic", "aggressive", "standard", "relaxed"]:
file = files.get(f"distanceIcons_{name}")
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
for f in dist_path.glob(f"{name}.*"):
f.unlink()
ext = Path(file.filename).suffix.lower()
save_path = dist_path / f"{name}{ext}"
file.save(save_path)
if ext == ".gif":
width, height = (250, 250)
palette_path = save_path.with_suffix(".palette.png")
temp_output_path = save_path.with_suffix(".resized.gif")
subprocess.run(["ffmpeg", "-i", str(save_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
subprocess.run(["ffmpeg", "-i", str(save_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
palette_path.unlink()
temp_output_path.rename(save_path)
else:
pil_image = _get_pillow_image()
img = pil_image.open(save_path).resize((250, 250), pil_image.Resampling.LANCZOS)
if ext != ".png":
save_path.unlink()
save_path = save_path.with_suffix(".png")
img.save(save_path, "PNG")
if save_checklist.get("sounds"):
sounds_path = theme_path / "sounds"
sounds_path.mkdir(exist_ok=True)
for name in ["engage", "disengage", "prompt", "startup"]:
file = files.get(name)
if file and file.filename:
if file.content_length > MAX_FILE_SIZE:
return None, f"File {file.filename} exceeds 1MB limit."
save_path = sounds_path / f"{name}{Path(file.filename).suffix}"
file.save(save_path)
covert_audio(str(save_path))
return theme_path, None
def decode_parameters(encoded_string):
obfuscated_data = base64.b64decode(encoded_string.encode("utf-8")).decode("utf-8")
decrypted_data = xor_encrypt_decrypt(obfuscated_data, XOR_KEY)
return json.loads(decrypted_data)
def encode_parameters(params_dict):
serialized_data = json.dumps(params_dict)
obfuscated_data = xor_encrypt_decrypt(serialized_data, XOR_KEY)
encoded_data = base64.b64encode(obfuscated_data.encode("utf-8")).decode("utf-8")
return encoded_data
def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
if not input_files:
raise ValueError("No input files provided for concatenation")
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
key_str = "|".join(str(p) for p in input_files)
if cache_key:
key_str = f"{cache_key}|{key_str}"
file_hash = hashlib.md5(key_str.encode()).hexdigest()
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
if cache_path.exists() and all(cache_path.stat().st_mtime > Path(f).stat().st_mtime for f in input_files):
return open(cache_path, "rb")
list_file = VIDEO_CACHE_PATH / f"{file_hash}.txt"
with open(list_file, "w") as f:
for seg in input_files:
f.write(f"file '{Path(seg)}'\n")
try:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(list_file), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
check=True
)
except subprocess.CalledProcessError:
try:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(list_file), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
check=True
)
except subprocess.CalledProcessError:
if cache_path.exists():
cache_path.unlink()
raise ValueError(f"Cannot process concatenated video segments: {input_files}")
finally:
if list_file.exists():
list_file.unlink()
return open(cache_path, "rb")
def ffmpeg_mp4_wrap_process_builder(filename):
input_path = Path(filename)
if not input_path.exists():
raise FileNotFoundError(f"Input file does not exist: {input_path}")
if input_path.stat().st_size == 0:
raise ValueError(f"Input file is empty: {input_path}")
lock_file = input_path.parent / "rlog.lock"
if lock_file.exists():
raise ValueError(f"File is still being recorded: {input_path}")
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
total, used, free = shutil.disk_usage(VIDEO_CACHE_PATH)
if free < 500 * 1024 * 1024:
for cache_file in VIDEO_CACHE_PATH.glob("*.mp4"):
try:
cache_file.unlink()
except:
pass
file_hash = hashlib.md5(str(input_path).encode()).hexdigest()
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
if cache_path.exists() and cache_path.stat().st_mtime > input_path.stat().st_mtime:
return open(cache_path, "rb")
try:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)], check=True)
except subprocess.CalledProcessError:
try:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)], check=True)
except subprocess.CalledProcessError:
if cache_path.exists():
cache_path.unlink()
raise ValueError(f"Cannot process video file: {input_path}")
return open(cache_path, "rb")
def format_git_date(raw_date: str):
date_object = datetime.strptime(raw_date.split()[1], "%Y-%m-%d")
day = date_object.day
suffix = "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
return date_object.strftime(f"%B {day}{suffix}, %Y")
def get_all_segment_names(footage_path):
entries = listdir_by_creation(footage_path)
segment_names = []
for entry in entries:
if not SEGMENT_RE.fullmatch(entry):
continue
segment_names.append(segment_to_segment_name(footage_path, entry))
return segment_names
def get_available_cameras(segment_path):
segment_path = Path(segment_path)
return [
name for name, file in {
"driver": "dcamera.hevc",
"forward": "fcamera.hevc",
"wide": "ecamera.hevc"
}.items() if (segment_path / file).exists()
]
def get_disk_usage():
free = get_available_bytes(default=0)
used = get_used_bytes(default=0)
total = used + free
def to_gb(b):
return f"{b // (2**30)} GB"
return [{
"free": to_gb(free),
"size": to_gb(total),
"used": to_gb(used),
"usedPercentage": f"{(used / total) * 100:.2f}%" if total > 0 else "0.00%"
}]
def get_drive_stats():
stats = _decode_json_param(params.get("ApiCache_DriveStats"), {})
starpilot_stats = _decode_json_param(params.get("StarPilotStats"), {})
is_metric = params.get_bool("IsMetric")
unit = "kilometers" if is_metric else "miles"
def process(timeframe):
data = stats.get(timeframe, {})
return {
"distance": data.get("distance", 0) * (1 if is_metric else CV.KPH_TO_MPH),
"drives": data.get("routes", 0),
"hours": data.get("minutes", 0) / 60,
"unit": unit
}
stats["all"] = process("all")
stats["week"] = process("week")
stats["starpilot"] = {
"distance": starpilot_stats.get("StarPilotMeters", 0) * (0.001 if is_metric else METER_TO_MILE),
"hours": starpilot_stats.get("StarPilotSeconds", 0) / (60 * 60),
"drives": starpilot_stats.get("StarPilotDrives", 0),
"unit": unit
}
return stats
def get_repo_owner(git_normalized_origin):
parts = git_normalized_origin.split("/")
return parts[1] if len(parts) >= 2 else "unknown"
def get_route_log_path(path):
target = Path(path)
if target.is_dir():
for candidate in ROUTE_TIME_LOG_CANDIDATES:
candidate_path = target / candidate
if candidate_path.exists():
return candidate_path
return None
if target.exists():
return target
if target.parent.is_dir():
for candidate in ROUTE_TIME_LOG_CANDIDATES:
candidate_path = target.parent / candidate
if candidate_path.exists():
return candidate_path
return None
def get_route_start_time(path):
log_path = get_route_log_path(path)
if log_path is None:
target = Path(path)
if not target.exists():
return None
log_path = target
try:
modified_time = log_path.stat().st_mtime
except OSError:
return None
if modified_time <= 0:
return None
return datetime.fromtimestamp(modified_time)
def get_routes_names(footage_path):
segments = get_all_segment_names(footage_path)
route_times = {segment.route_name.time_str for segment in segments}
return sorted(route_times, reverse=True)
def get_segments_in_route(route_time_str, footage_path):
return [
f"{segment.time_str}--{segment.segment_num}"
for segment in get_all_segment_names(footage_path)
if segment.time_str == route_time_str
]
def get_video_duration(input_path):
try:
result = subprocess.run([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(input_path)
], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
return float(result.stdout)
except (ValueError, subprocess.CalledProcessError):
return 60
def has_preserve_attr(path: str):
return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
def list_file(path):
return sorted(os.listdir(path), reverse=True)
def normalize_theme_name(name, for_path=False):
name = name.replace("-user_created", "")
if for_path:
return name.lower().replace(" (", "-").replace(")", "").replace(" ", "-").replace("'", "").replace(".", "")
parts = re.split(r'[-_]', name)
normalized_parts = [part.capitalize() for part in parts]
if '-' in name and len(normalized_parts) > 1:
return f"{normalized_parts[0]} ({' '.join(normalized_parts[1:])})".replace(" Week", "")
return ' '.join(normalized_parts).replace(" Week", "")
def process_route(footage_path, route_name):
segment_path = f"{footage_path}{route_name}--0"
qcamera_path = f"{segment_path}/qcamera.ts"
png_output_path = os.path.join(segment_path, "preview.png")
if not os.path.exists(png_output_path):
video_to_png(qcamera_path, png_output_path)
custom_name = None
if os.path.isdir(segment_path):
for item in os.listdir(segment_path):
if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in LOG_CANDIDATES:
custom_name = item
break
route_timestamp_str = custom_name
if not custom_name:
route_timestamp_dt = get_route_start_time(segment_path)
route_timestamp_str = route_timestamp_dt.isoformat() if route_timestamp_dt else None
return {
"name": route_name,
"png": f"/thumbnails/{route_name}--0/preview.png",
"timestamp": route_timestamp_str,
"is_preserved": has_preserve_attr(segment_path)
}
def process_screen_recording(mp4):
stem = mp4.with_suffix("")
png_path = stem.with_suffix(".png")
if not png_path.exists():
video_to_png(mp4, png_path)
is_custom_name = False
try:
datetime.strptime(stem.name, "%B_%d_%Y-%I-%M%p")
except ValueError:
is_custom_name = True
return {
"filename": mp4.name,
"png": f"/screen_recordings/{png_path.name}",
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
"is_custom_name": is_custom_name
}
def segment_to_segment_name(data_dir, segment):
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
return SegmentName(full_path)
def video_to_png(input_path, output_path):
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", "1",
"-i", str(input_path),
"-frames:v", "1",
"-y",
str(output_path)
], capture_output=True, check=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Failed to generate PNG for {input_path}")
if e.stderr:
print(e.stderr)
def xor_encrypt_decrypt(data, key):
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))