initial commit
commit
525aa1a0d8
|
@ -0,0 +1,5 @@
|
|||
/media/*
|
||||
/thumbs/*
|
||||
gallery-items.csv
|
||||
gallery-items-backup-*
|
||||
__pycache__
|
|
@ -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))
|
||||
|
|
@ -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 (" ")
|
||||
# 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(" ", " "))
|
||||
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 "," 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)
|
|
@ -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,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."]
|
||||
}
|
Loading…
Reference in New Issue