initial commit

main
nekkowe 2025-01-09 17:35:41 +01:00
commit 525aa1a0d8
6 changed files with 415 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/media/*
/thumbs/*
gallery-items.csv
gallery-items-backup-*
__pycache__

View File

@ -0,0 +1,161 @@
import os, csv
from datetime import datetime
from pathlib import Path
from ffmpeg import FFmpeg, Progress
from lib import GalleryTable
TABLE_PATH = Path(__file__, "..", "gallery-items.csv").resolve()
MEDIA_FOLDER = Path(__file__, "..", "media").resolve()
THUMBNAIL_FOLDER = Path(__file__, "..", "thumbs").resolve()
THUMBNAIL_SUFFIX = "-thumb.webp"
THUMBNAIL_MAX_FRAMES = 0 # No limit. Feel free to adjust this if you're hosting long animations on there or something.
def generate_and_register_thumbnail(file_path, thumbnail_path):
try:
generate_thumbnail(file_path, thumbnail_path)
table[file_name]["Thumbnail"] = thumbnail_path.name
print("\n✅ Generated thumbnail successfully: {}".format(thumbnail_path.name))
except Exception as error:
print("\n⚠️ Error while generating thumbnail for {}:".format(file_path.name))
print("{error_name}: {error}".format(error_name=type(error).__name__, error=error))
print("Continuing on...")
def generate_thumbnail(file_path, thumbnail_path):
ffmpeg = (
FFmpeg()
.option("n") # Do not overwrite existing files (we already checked for them earlier, so this is just to be sure)
.input(file_path) # Input file
.output(
thumbnail_path, # Output file
vf="scale=300:300:force_original_aspect_ratio=increase", # Resize smallest side to 300px while preserving aspect ratio
vcodec="webp", # Webp files are TINY and load super fast. Incredible file format.
lossless=0,
quality=90, # Basically undetectable in a thumbnail, even for animations.
compression_level=6, # Highest compression level, takes a little longer to process
loop=0 # Infinite loop (e.g. for thumbnails of animated gifs)
)
)
@ffmpeg.on("start") # Log ffmpeg arguments for thumbnail generation at the start
def on_start(arguments: list[str]):
print("Running FFmpeg with arguments:\n", arguments, "\n")
@ffmpeg.on("progress") # Log thumbnail generation progress as it happens
def on_progress(progress: Progress):
print("Progress:", progress)
if THUMBNAIL_MAX_FRAMES > 0 and progress.frame > THUMBNAIL_MAX_FRAMES:
ffmpeg.terminate()
ffmpeg.execute() # Go!!!
def prompt_confirmation(prompt_message):
answer = input(prompt_message + "\n")
return answer.lower() in ["y", "yes"] # Accept "y", "Y", "yes", "Yes" etc.
def print_horizontal_separator():
print("--------------")
if __name__ == "__main__": # Only do this when the script is run directly
print("Generating thumbnails...")
# Load existing table, if one does exists:
if TABLE_PATH.is_file():
table = GalleryTable.parse_table(TABLE_PATH)
else:
table = {}
# Generate thumbnails and table information
# for all images discovered in the folder:
for file_name in os.listdir(MEDIA_FOLDER):
print("Checking \"{}\"...".format(file_name))
file_path = Path(MEDIA_FOLDER, file_name).resolve()
if file_name in table.keys():
# Mark the existing table entry as valid:
table[file_name]["present"] = True
else:
# Make a new table entry for images that don't have one yet:
table[file_name] = {
"File": file_name,
"Thumbnail": "",
"Title": file_path.stem,
"Description": "<Description for \"{}\" here>".format(file_name),
"Tags (with commas in-between)": "Example Tag 1, Example Tag 2",
"present": True
}
# Check whether a thumbnail already exists,
# or has to be generated - there are a lot of
# potential edge cases to address here:
if not table[file_name]["Thumbnail"]:
# There's no thumbnail for this image listed in the table yet...
thumbnail_path = Path(THUMBNAIL_FOLDER, file_name + THUMBNAIL_SUFFIX).resolve()
if thumbnail_path.is_file():
# ...but a thumbnail with the default name already exists:
table[file_name]["Thumbnail"] = thumbnail_path.name # Write the existing thumbnail into the table
print("✅ Thumbnail already exists (will be added to {}).".format(TABLE_PATH))
else:
# ...and no thumbnail with the default name already exists:
print("Thumbnail does not exist, generating...")
generate_and_register_thumbnail(file_path, thumbnail_path)
else:
# There's a thumbnail for this image listed in the table already...
thumbnail_path = Path(THUMBNAIL_FOLDER, table[file_name]["Thumbnail"]).resolve()
if thumbnail_path.is_file():
# ...and it does actually exist inside the THUMBNAIL_FOLDER:
print("✅ Thumbnail already exists.")
else:
# ...but it doesn't actually exist inside the THUMBNAIL_FOLDER:
print("⚠️ Thumbnail {} is listed in {}, but was not found.".format(thumbnail_path, TABLE_PATH))
if prompt_confirmation("Generate a thumbnail with that name? [Y/n]"):
print("Generating thumbnail...")
generate_and_register_thumbnail(file_path, thumbnail_path)
else:
print("Ignoring missing thumbnail {}.".format(thumbnail_path))
print_horizontal_separator()
# Identify images that were deleted from the folder,
# but not from the table, and offer to remove them:
orphaned_entries = {name: entry for name, entry in table.items() if not entry.get("present")}
if orphaned_entries:
print("Found {} gallery entries missing their source images:".format(len(orphaned_entries)))
for name, entry in orphaned_entries.items():
print(name)
if prompt_confirmation("Remove these orphaned entries from the gallery? [yes/no]"):
print("Removing {} orphaned entries from gallery...".format(len(orphaned_entries)))
for name, entry in orphaned_entries.items():
table.pop(name)
else:
print("Ignoring orphaned entries. They will remain in the gallery table for you to edit later.")
print_horizontal_separator()
# Back up the existing table and write out the new one:
if TABLE_PATH.is_file():
print("Backing up the existing {}...".format(TABLE_PATH))
backup_suffix = "-backup-" + datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
os.rename(TABLE_PATH, TABLE_PATH.with_stem(TABLE_PATH.stem + backup_suffix))
print("Writing out new table...")
try:
GalleryTable.write_table(TABLE_PATH, table)
print("Done all done!")
print("Edit {} to add titles, descriptions and tags.".format(TABLE_PATH))
print("When finished, run the other script!")
except Exception as error:
print("Error writing table:")
print("{error_name}: {error}".format(error_name=type(error).__name__, error=error))

138
2-generate-gallery.py Normal file
View File

@ -0,0 +1,138 @@
import os, csv, inspect, json, textwrap
from datetime import datetime
from pathlib import Path
from lib import GalleryTable
GALLERY_ID = "gallery" # e.g. <div id="gallery"/>
GALLERY_CONFIG_PATH = Path(__file__, "..", "nanogallery2-config.json").resolve()
JAVASCRIPT_OUTPUT_PATH = Path(__file__, "..", "gallery.js").resolve()
HTML_OUTPUT_PATH = Path(__file__, "..", "gallery.html").resolve()
TABLE_PATH = Path(__file__, "..", "gallery-items.csv").resolve()
MEDIA_FOLDER = Path(__file__, "..", "media").resolve()
THUMBNAIL_FOLDER = Path(__file__, "..", "thumbs").resolve()
def format_tags(tag_string):
# Nanogallery2 expects space-separated tags,
# which isn't intuitive in cases where you
# want a tag to contain a space.
#
# So instead, this script (and the table) expects comma-separated tags,
# then trims any whitespace from the start and end,
# replaces all normal spaces with non-breaking spaces ("&nbsp;")
# automatically, and finally puts normal spaces
# inbetween each tag, the way Nanogallery2 expects them.
tags = []
for tag in tag_string.split(","):
tags.append(tag.strip().replace(" ", "&nbsp;"))
return " ".join(tags)
# If you want to use *commas* in a tag, I'm sorry.
# Please let me know. I'll find a solution.
# In the meantime, you can write "&comma;" whereever you need one.
# I just don't want to get stuck overengineering
# this any harder than I already am BEFORE I have
# something finished that most people can put to use.
#
# The setup for all this is already going to be a tall ask.
# Installing Python and FFmpeg isn't something I'd ask of a parent.
# Unless they're on Linux in which case it's like, super trivial to do.
# But that's not most parents. I think? I think.
#
# I mean, the reason this is all in Python to begin with
# is purely so I only have to write it once, and people can
# use it regardless of whether they use Windows, Linux, or Mac,
# but also still readily look inside or tweak stuff easily.
# And FFmpeg is required for the thumbnail generation stuff.
# So there *is* a point to it all, but every extra step of setup
# I ask people to do gets me worried that it'll be The step
# that makes someone give up on the whole thing...
#
# Anyway. That's why I reformat the tags here.
# It's so you can have spaces in them easystyle.
# You're welcome. Sorry again if you needed commas and stuff.
if __name__ == "__main__": # Only do this when the script is run directly
table = GalleryTable.parse_table(TABLE_PATH)
print("Found {} gallery items in {}.".format(len(table), TABLE_PATH))
try:
with open(GALLERY_CONFIG_PATH, "r") as file:
gallery_config = json.load(file)
except Exception as error:
print("Could not find/load {}:".format(GALLERY_CONFIG_PATH))
print(error)
print("Continuing with default gallery configuration.")
gallery_config = {}
gallery_items = []
print("Creating HTML and JS...")
for name, entry in table.items():
gallery_items.append({
"src": "/media/" + entry["File"],
"srct": "/thumbs/" + entry["Thumbnail"],
"title": entry["Title"],
"description": entry["Description"],
"tags": format_tags(entry["Tags (with commas in-between)"])
})
gallery_config["items"] = gallery_items
output_js = inspect.cleandoc(
"""
jQuery(document).ready(function () {{
jQuery("#{gallery_id}").nanogallery2(
{gallery_config}
)
}})
"""
).format(
gallery_id=GALLERY_ID,
gallery_config=textwrap.indent(
json.dumps(
gallery_config,
indent=2
), " ")
)
output_html = inspect.cleandoc(
"""
<!DOCTYPE html>
<head>
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<!-- These are required to load the nanogallery2 code: -->
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/nanogallery2@3/dist/css/nanogallery2.min.css" rel="stylesheet" type="text/css">
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/nanogallery2@3/dist/jquery.nanogallery2.min.js"></script>
<!-- This contains the settings and gallery items for your personal gallery: -->
<script type="text/javascript" src="gallery.js"></script>
</head>
<body>
<h1>Gallery</h1>
<!-- Below is the actual gallery: -->
<div id="{gallery_id}"/>
</body>
"""
).format(
gallery_id=GALLERY_ID
)
try:
with open(HTML_OUTPUT_PATH, "w", encoding="utf-8") as html_output_file:
html_output_file.write(output_html)
with open(JAVASCRIPT_OUTPUT_PATH, "w", encoding="utf-8") as js_output_file:
js_output_file.write(output_js)
print("All done!")
except Exception as error:
print("Could not write HTML and JS files:")
print(error)

17
lib/GalleryTable.py Normal file
View File

@ -0,0 +1,17 @@
import csv
GALLERY_TABLE_HEADERS = ["File", "Thumbnail", "Title", "Description", "Tags (with commas in-between)"]
def parse_table(file_path):
results = {}
with open(file_path, "r", encoding="utf-8") as file:
reader = csv.DictReader(file)
for row in reader:
results[row["File"]] = row
return results
def write_table(file_path, results):
with open(file_path, "w", encoding="utf-8") as file:
writer = csv.DictWriter(file, GALLERY_TABLE_HEADERS, quoting=csv.QUOTE_ALL, extrasaction="ignore")
writer.writeheader()
writer.writerows(results.values())

0
lib/__init__.py Normal file
View File

94
nanogallery2-config.json Normal file
View File

@ -0,0 +1,94 @@
{
"itemsBaseURL": ".",
"thumbnailLevelUp": true,
"thumbnailOpenInLightbox": true,
"thumbnailWaitImageLoaded": true,
"imageTransition": "swipe2",
"slideshowAutoStart": false,
"slideshowDelay": 3000,
"viewerHideToolsDelay": 3000,
"viewerFullscreen": false,
"viewerGallery": "bottom",
"viewerGalleryTWidth": 100,
"viewerGalleryTHeight": 100,
"galleryNavigationOverlayButtons": true,
"touchAnimation": true,
"touchAutoOpenDelay": 0,
"colorScheme": {
"thumbnail": {
"borderColor": "rgba(0,0,0,0.75)"
}
},
"thumbnailWidth": 200,
"thumbnailHeight": 200,
"thumbnailAlignment": "center",
"thumbnailCrop": true,
"thumbnailGutterWidth": 2,
"thumbnailGutterHeight": 2,
"thumbnailBorderHorizontal": 2,
"thumbnailBorderVertical": 2,
"thumbnailLabel": {
"position": "overImage",
"align": "left",
"valign": "bottom",
"display": true,
"hideIcons": true,
"titleMultiline": true,
"titleMaxLength": 0,
"displayDescription": true,
"descriptionMultiLine": true,
"descriptionMaxLength": 0
},
"allowHTMLinData": true,
"thumbnailHoverEffect2": "labelAppear75|imageScale150|descriptionAppear|toolsappear",
"thumbnailToolbarImage": {
"topLeft": "share",
"topRight": "info",
"bottomLeft": "",
"bottomRight": ""
},
"viewerToolbar": {
"display": true,
"position": "bottom",
"align": "right",
"fullwidth": false,
"autoMinimize": 800,
"standard": "minimizeButton, label",
"minimized": "minimizeButton, label, fullscreenButton, downloadButton, infoButton"
},
"viewerTools": {
"topLeft": "previousButton, pageCounter, nextButton, playPauseButton",
"topRight": "infoButton, zoomButton, fullscreenButton, linkOriginalButton, downloadButton, shareButton, closeButton"
},
"viewerTheme": "dark",
"viewerImageDisplay": "bestImageQuality",
"viewerTransitionMediaKind": "img",
"galleryDisplayMode": "pagination",
"galleryPaginationTopButtons": true,
"galleryPaginationMode": "numbers",
"galleryMaxRows": 3,
"paginationVisiblePages": 10,
"gallerySorting": "",
"galleryFilterTags": false,
"galleryFilterTagsMode": "multiple",
"thumbnailDisplayTransition": "scaleUp",
"thumbnailDisplayTransitionDuration": 500,
"thumbnailDisplayTransitionEasing": "easeOutQuart",
"thumbnailDisplayInterval": 30,
"galleryDisplayTransition": "rotateX",
"galleryDisplayTransitionDuration": 500,
"items": ["You can leave this one alone. The script will fill it in."]
}