├── .dockerignore ├── .gitignore ├── LICENSE ├── README.md ├── docker ├── Dockerfile ├── create_docker_image.sh ├── start.sh └── template_config.yml ├── fussel ├── __init__.py ├── fussel.py ├── generator │ ├── __init__.py │ ├── config.py │ ├── generate.py │ └── util.py ├── requirements.txt └── web │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── robots.txt │ ├── src │ ├── component │ │ ├── App.js │ │ ├── Collection.css │ │ ├── Collection.js │ │ ├── Collections.css │ │ ├── Collections.js │ │ ├── Navbar.css │ │ ├── Navbar.js │ │ ├── NotFound.css │ │ ├── NotFound.js │ │ ├── fussel.code-workspace │ │ └── withRouter.js │ ├── images │ │ ├── animal-track-transparent-2.png │ │ ├── animal-track-transparent.png │ │ ├── animal-track.jpg │ │ └── fussel-watermark.png │ ├── index.css │ └── index.jsx │ └── yarn.lock ├── generate.sh ├── resources ├── animal-track-2.xcf ├── animal-track.xcf └── fussel-watermark.xcf ├── sample_config.yml └── setup.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | *.swp 6 | .env 7 | .venv 8 | *.yaml 9 | *.yml 10 | resources/ 11 | fussel/.venv 12 | fussel/.venv 13 | fussel/web/src/_gallery 14 | fussel/web/build/ 15 | fussel/web/public/static/_gallery 16 | fussel/web/node_modules 17 | fussel/web/logs 18 | fussel/web/*.log 19 | fussel/web/npm-debug.log* 20 | fussel/web/yarn-debug.log* 21 | fussel/web/yarn-error.log* 22 | fussel/web/lerna-debug.log* 23 | 24 | # Diagnostic reports (https://nodejs.org/api/report.html) 25 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | bower_components 48 | 49 | # node-waf configuration 50 | .lock-wscript 51 | 52 | # Compiled binary addons (https://nodejs.org/api/addons.html) 53 | build/Release 54 | 55 | # Dependency directories 56 | fussel/web/node_modules/ 57 | fussel/web/jspm_packages/ 58 | 59 | # TypeScript v1 declaration files 60 | typings/ 61 | 62 | # TypeScript cache 63 | *.tsbuildinfo 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Microbundle cache 72 | .rpt2_cache/ 73 | .rts2_cache_cjs/ 74 | .rts2_cache_es/ 75 | .rts2_cache_umd/ 76 | 77 | # Optional REPL history 78 | .node_repl_history 79 | 80 | # Output of 'npm pack' 81 | *.tgz 82 | 83 | # Yarn Integrity file 84 | .yarn-integrity 85 | 86 | # dotenv environment variables file 87 | .env 88 | .env.test 89 | 90 | # parcel-bundler cache (https://parceljs.org/) 91 | .cache 92 | 93 | # Next.js build output 94 | .next 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | *.swp 6 | .env 7 | 8 | fussel/web/src/_gallery 9 | fussel/web/build/ 10 | fussel/web/public/static/_gallery 11 | fussel/web/node_modules 12 | fussel/.venv 13 | 14 | # Logs 15 | fussel/web/logs 16 | fussel/web/*.log 17 | fussel/web/npm-debug.log* 18 | fussel/web/yarn-debug.log* 19 | fussel/web/yarn-error.log* 20 | fussel/web/lerna-debug.log* 21 | 22 | 23 | # Diagnostic reports (https://nodejs.org/api/report.html) 24 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | coverage 37 | *.lcov 38 | 39 | # nyc test coverage 40 | .nyc_output 41 | 42 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | bower_components 47 | 48 | # node-waf configuration 49 | .lock-wscript 50 | 51 | # Compiled binary addons (https://nodejs.org/api/addons.html) 52 | build/Release 53 | 54 | # Dependency directories 55 | fussel/web/node_modules/ 56 | fussel/web/jspm_packages/ 57 | 58 | # TypeScript v1 declaration files 59 | typings/ 60 | 61 | # TypeScript cache 62 | *.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | .npm 66 | 67 | # Optional eslint cache 68 | .eslintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variables file 86 | .env 87 | .env.test 88 | 89 | # parcel-bundler cache (https://parceljs.org/) 90 | .cache 91 | 92 | # Next.js build output 93 | .next 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # Serverless directories 109 | .serverless/ 110 | 111 | # FuseBox cache 112 | .fusebox/ 113 | 114 | # DynamoDB Local files 115 | .dynamodb/ 116 | 117 | # TernJS port file 118 | .tern-port 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 - present Christopher Benninger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Fussel 2 | 3 | ![License Badge](https://img.shields.io/github/license/cbenning/fussel) 4 | ![Version Badge](https://img.shields.io/github/v/release/cbenning/fussel) 5 | 6 | Fussel is a static photo gallery generator. It can build a simple static photo gallery site 7 | with nothing but a directory full of photos. 8 | 9 | **[Demo Site](https://benninger.ca/fussel-demo/)** 10 | 11 | Features and Properties: 12 | - No server-side code to worry about once generated 13 | - Builds special "Person" gallery for people found in XMP face tags. 14 | - Adds watermarks 15 | - Mobile friendly 16 | - Automatic dark-mode 17 | - Uses EXIF hints to rotate photos 18 | - Predictable slug-basted urls 19 | 20 | Common Use-cases: 21 | - Image Portfolios 22 | - Family Photos 23 | - Sharing Photo Archives 24 | - etc 25 | 26 | ## Screenshots 27 | | ![Albums Screenshot](https://user-images.githubusercontent.com/153700/81897761-1e904780-956c-11ea-9450-fbdb286b95fc.png?raw=true "Albums Screenshot") | ![Album Screenshot](https://user-images.githubusercontent.com/153700/81897716-120bef00-956c-11ea-9204-b8e90ffb24f8.png?raw=true "Album Screenshot") | 28 | |---|---| 29 | | ![People Screenshot](https://user-images.githubusercontent.com/153700/81897685-fef91f00-956b-11ea-8df6-9c23fad83bb2.png?raw=true "People Screenshot") | ![Person Screenshot](https://user-images.githubusercontent.com/153700/81897698-091b1d80-956c-11ea-9acb-6195d9673407.png?raw=true "PersonScreenshot") | 30 | 31 | ## Demo 32 | ![Demo Gif](https://user-images.githubusercontent.com/153700/81898094-d58cc300-956c-11ea-90eb-f8ce5561f63d.gif?raw=true "Modal Screenshot") 33 | 34 | ## Setup 35 | 36 | ### Requirements 37 | 38 | - Python 3.7+ 39 | - node v18.14.0 LTS 40 | - npm v9.3.1 41 | - yarn 1.22 42 | 43 | ## Install dependencies 44 | 45 | - `./setup.sh` 46 | 47 | ## Setup Site 48 | 49 | ### Configure 50 | 51 | - Copy `sample_config.yml` to `config.yml` 52 | - Edit `config.yml` to your needs (minimal change is to set INPUT_PATH) 53 | 54 | ### Curate photos 55 | The folder you point `gallery.input_path` at must have subfolders inside it with the folder names as the name of the albums you want in the gallery. 56 | 57 | #### Example 58 | 59 | If you have your .env setup with: 60 | ``` 61 | gallery: 62 | input_path: "/home/user/Photos/gallery" 63 | ``` 64 | 65 | Then that path should look like this: 66 | ``` 67 | /home/user/Photos/gallery: 68 | - Album 1 69 | - Album 2 70 | - Sub Album 1 71 | - Album 3 72 | - Sub Album 2 73 | - ... 74 | ``` 75 | 76 | ### Generate your site 77 | Run the following script to generate your site into the path you set in `gallery.output_path` folder. 78 | - `./generate_site.sh` 79 | 80 | ### Host your site 81 | 82 | Point your web server at `gallery.output_path` folder or copy/upload the `gallery.output_path` folder to your web host HTTP root. 83 | 84 | #### Quick setup 85 | 86 | After running `generate_site.sh` 87 | 88 | - `python -m http.server --directory ` (go to localhost:8000 in browser) 89 | 90 | #### Development setup 91 | 92 | - `cd fussel/web` 93 | - `yarn start` 94 | 95 | ## Docker 96 | 97 | If you don't want to fuss with anything and would like to use docker instead to generate your site... 98 | 99 | ### Usage 100 | 101 | Required: 102 | * `` is the absolute path to top-level photo folder 103 | * `` is the absolute path to where you want the generated site written to 104 | 105 | Note: 106 | * The two -e env variables PGID and PUID tells the container what to set the output folder permissions to 107 | once done. Otherwise it is set to root permissions 108 | * Look at docker/template_config.yml To see what ENV vars map to which config values 109 | 110 | ``` 111 | docker run \ 112 | -v :/input:ro \ 113 | -v :/output \ 114 | -e PGID=$(id -g) \ 115 | -e PUID=$(id -u) \ 116 | -e INPUT_PATH="/input" \ 117 | -e OUTPUT_PATH="/output" \ 118 | -e OVERWRITE="False" \ 119 | -e EXIF_TRANSPOSE="False" \ 120 | -e RECURSIVE="True" \ 121 | -e RECURSIVE_NAME_PATTERN="{parent_album} > {album}" \ 122 | -e FACE_TAG_ENABLE="True" \ 123 | -e WATERMARK_ENABLE="True" \ 124 | -e WATERMARK_PATH="web/src/images/fussel-watermark.png" \ 125 | -e WATERMARK_SIZE_RATIO="0.3" \ 126 | -e SITE_ROOT="/" \ 127 | -e SITE_TITLE="Fussel Gallery" \ 128 | ghcr.io/cbenning/fussel:latest 129 | ``` 130 | 131 | Once complete you can upload the output folder to your webserver, or see what it looks like with 132 | `python -m http.server --directory ` 133 | 134 | 135 | ## FAQ 136 | 137 | ### I get an error 'JavaScript heap out of memory' 138 | 139 | Try increasing your Node memory allocation: `NODE_OPTIONS="--max-old-space-size=2048" yarn build` 140 | 141 | Reference: https://github.com/cbenning/fussel/issues/25 -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | LABEL org.opencontainers.image.source=https://github.com/cbenning/fussel 4 | LABEL org.opencontainers.image.description="A static photo gallery generator " 5 | LABEL org.opencontainers.image.licenses=MIT 6 | 7 | WORKDIR /fussel 8 | 9 | COPY . /fussel/ 10 | 11 | RUN apk add --no-cache curl jq yq python3 python3-dev py3-pip nodejs yarn sed bash && \ 12 | apk add --no-cache libjpeg-turbo libjpeg-turbo-dev zlib zlib-dev libc-dev gcc && \ 13 | yarn config set disable-self-update-check true && \ 14 | ./setup.sh && \ 15 | apk del python3-dev py3-pip libjpeg-turbo-dev zlib-dev libc-dev gcc && \ 16 | rm -r /usr/local/share/.cache 17 | 18 | WORKDIR / 19 | 20 | COPY docker/start.sh / 21 | COPY docker/template_config.yml / 22 | 23 | CMD ["/start.sh"] 24 | -------------------------------------------------------------------------------- /docker/create_docker_image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -e 4 | docker build -f Dockerfile -t fussel ../ 5 | 6 | echo "What would you like to tag this image with?" 7 | read TAG 8 | docker tag fussel fussel:${TAG} 9 | 10 | echo "Would you like to publish? (y/N)" 11 | read DO_PUBLISH 12 | if [ "$DO_PUBLISH" == "y" ] || [ "$DO_PUBLISH" == "Y" ] 13 | then 14 | docker tag fussel:${TAG} ghcr.io/cbenning/fussel:${TAG} 15 | docker push ghcr.io/cbenning/fussel:${TAG} 16 | else 17 | exit 0 18 | fi 19 | 20 | echo "Would you like to mark as latest? (y/N)" 21 | read DO_LATEST 22 | if [ "$DO_LATEST" == "y" ] || [ "$DO_LATEST" == "Y" ] 23 | then 24 | docker tag ghcr.io/cbenning/fussel:${TAG} ghcr.io/cbenning/fussel:latest 25 | docker push ghcr.io/cbenning/fussel:latest 26 | else 27 | exit 0 28 | fi 29 | 30 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd fussel 4 | 5 | set -e 6 | 7 | source fussel/.venv/bin/activate 8 | 9 | echo "Generating yaml config..." 10 | 11 | jinja2 \ 12 | -D INPUT_PATH="\"${INPUT_PATH}\"" \ 13 | -D OUTPUT_PATH="\"${OUTPUT_PATH}\"" \ 14 | -D OVERWRITE="${OVERWRITE}" \ 15 | -D EXIF_TRANSPOSE="${EXIF_TRANSPOSE}" \ 16 | -D RECURSIVE="${RECURSIVE}" \ 17 | -D RECURSIVE_NAME_PATTERN="\"${RECURSIVE_NAME_PATTERN}\"" \ 18 | -D FACE_TAG_ENABLE="${FACE_TAG_ENABLE}" \ 19 | -D WATERMARK_ENABLE="${WATERMARK_ENABLE}" \ 20 | -D WATERMARK_PATH="\"${WATERMARK_PATH}\"" \ 21 | -D WATERMARK_SIZE_RATIO="${WATERMARK_SIZE_RATIO}" \ 22 | -D SITE_ROOT="\"${SITE_ROOT}\"" \ 23 | -D SITE_TITLE="\"${SITE_TITLE}\"" \ 24 | ../template_config.yml > config.yml 25 | 26 | cat config.yml 27 | 28 | ./generate.sh 29 | 30 | 31 | _PUID=$(id -u) 32 | _PGID=$(id -g) 33 | DO_CHOWN=0 34 | if [[ ! -z "${PUID}" ]]; then 35 | _PUID=${PUID} 36 | DO_CHOWN=1 37 | fi 38 | if [[ ! -z "${PGID}" ]]; then 39 | _PGID=${PGID} 40 | DO_CHOWN=1 41 | fi 42 | if (( DO_CHOWN > 0 )); then 43 | echo "Fixing output directory permissions..." 44 | chown -R ${_PUID}:${_PGID} ${OUTPUT_PATH} 45 | fi 46 | -------------------------------------------------------------------------------- /docker/template_config.yml: -------------------------------------------------------------------------------- 1 | 2 | gallery: 3 | # Path to the root of your photo files 4 | input_path: {{ INPUT_PATH }} 5 | 6 | # Path to output the site to 7 | # Default: "site/" 8 | output_path: {{ OUTPUT_PATH | default('site/') }} 9 | 10 | # Setting to True will force a full rebuild of the gallery photos. 11 | # Setting to False will only generate files which are missing or added 12 | # Default: False 13 | overwrite: {{ OVERWRITE | default('False') }} 14 | 15 | # The number of parallel processing tasks that will be used 16 | # Default: / 2 17 | # parallel_tasks: {{ PARALLEL_TASKS | default(1) }} 18 | 19 | # Attempt to orient the photo based on embedded EXIF data 20 | # Default: exif_transpose: False 21 | exif_transpose: {{ EXIF_TRANSPOSE | default('False') }} 22 | 23 | albums: 24 | # Recursively process sub-folders as albums 25 | # Default: recursive: True 26 | recursive: {{ RECURSIVE | default('True') }} 27 | 28 | # Pattern for album name creation of sub albums 29 | # Following substitutions are available: 30 | # - {parent_album} will be replaced with the parent album 31 | # - {album} will be replaced with the album name 32 | # Default: "{parent_album} > {album}" 33 | recursive_name_pattern: {{ RECURSIVE_NAME_PATTERN | default('{parent_album} > {album}') }} 34 | 35 | people: 36 | # Face Tag detection. 37 | # Setting to True adds a faces button and virtual albums for detected people 38 | # Setting to False disables the feature 39 | # Default: True 40 | enable: {{ FACE_TAG_ENABLE | default('True') }} 41 | 42 | watermark: 43 | # Enable placement of watermark in bottom right corner of photos. 44 | # Default: True 45 | enable: {{ WATERMARK_ENABLE | default('True') }} 46 | 47 | # Specify path to custome watermark 48 | # Should be a transparent png 49 | # Default: "web/src/images/fussel-watermark.png" 50 | path: {{ WATERMARK_PATH | default('web/src/images/fussel-watermark.png') }} 51 | 52 | # Watermark size ratio (0.0 -> 1.0) 53 | # Default: 0.3 54 | size_ratio: {{ WATERMARK_SIZE_RATIO | default('0.3') }} 55 | 56 | site: 57 | # Set http_root to the root url you intend to host the site. 58 | # If you host this service at http:/// set to http:///'. 59 | # If you host this service at http:///my/photo/album/ set to 'http:///my/photo/album/' 60 | # Include trailing slash 61 | # Default: "/" 62 | http_root: {{ SITE_ROOT | default('/') }} 63 | 64 | # Title shown at the top of the browser tab 65 | # Default: "Fussel Gallery" 66 | title: {{ SITE_TITLE | default('Fussel Gallery') }} 67 | -------------------------------------------------------------------------------- /fussel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/__init__.py -------------------------------------------------------------------------------- /fussel/fussel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from generator import SiteGenerator 5 | import massedit 6 | import yaml 7 | import shutil 8 | 9 | 10 | class YamlConfig: 11 | def __init__(self, config_file='../config.yml'): 12 | 13 | self.cfg = {} 14 | with open(config_file, 'r') as stream: 15 | self.cfg = yaml.safe_load(stream) 16 | 17 | input_path = self.getKey('gallery.input_path') 18 | if not os.path.isdir(input_path): 19 | print(f'Invalid input path: {input_path}') 20 | exit(-1) 21 | 22 | output_path = self.getKey('gallery.output_path') 23 | if os.path.exists(output_path) and not os.path.isdir(output_path): 24 | print(f'Invalid output path: {output_path}') 25 | exit(-1) 26 | 27 | def getKey(self, path: str, default=None): 28 | if path in self.cfg: 29 | self.cfg.get(path) 30 | keys = path.split(".") 31 | cursor = self.cfg 32 | for i, k in enumerate(keys): 33 | if i >= len(keys) - 1: 34 | break 35 | cursor = cursor.get(k, {}) 36 | return cursor.get(k, default) 37 | 38 | 39 | def main(): 40 | 41 | cfg = YamlConfig() 42 | 43 | generator = SiteGenerator(cfg) 44 | generator.generate() 45 | 46 | http_root = cfg.getKey('site.http_root', '/') 47 | filenames = [os.path.join(os.path.dirname( 48 | os.path.realpath(__file__)), "web", "package.json")] 49 | massedit.edit_files(filenames, [ 50 | "re.sub(r'^.*\"homepage\":.*$', ' \"homepage\": \""+http_root+"\",', line)"], dry_run=False) 51 | 52 | os.chdir('web') 53 | if os.system('yarn build') != 0: 54 | print("Failed") 55 | exit(-1) 56 | os.chdir('..') 57 | 58 | site_location = os.path.normpath(os.path.join( 59 | os.path.dirname(os.path.realpath(__file__)), "web", "build")) 60 | 61 | output_path = cfg.getKey('gallery.output_path', 'site/') 62 | if os.path.isabs(output_path): 63 | new_site_location = os.path.normpath(output_path) 64 | else: 65 | new_site_location = os.path.normpath(os.path.join( 66 | os.path.dirname(os.path.realpath(__file__)), "..", output_path)) 67 | print("Copying site to output location...") 68 | print(f' {site_location} ---> {new_site_location}') 69 | shutil.copytree( 70 | site_location, 71 | new_site_location, 72 | symlinks=False, 73 | ignore=None, 74 | ignore_dangling_symlinks=False, 75 | dirs_exist_ok=True) 76 | 77 | print(f'site generated at: {new_site_location}') 78 | print( 79 | f'\n\n to validate build run: \n python -m http.server --directory {new_site_location}') 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /fussel/generator/__init__.py: -------------------------------------------------------------------------------- 1 | from .generate import * -------------------------------------------------------------------------------- /fussel/generator/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | DEFAULT_WATERMARK_PATH = 'web/src/images/fussel-watermark.png' 5 | DEFAULT_WATERMARK_SIZE_RATIO = 0.3 6 | DEFAULT_RECURSIVE_ALBUMS_NAME_PATTERN = '{parent_album} > {album}' 7 | DEFAULT_OUTPUT_PHOTOS_PATH = 'site/' 8 | DEFAULT_SITE_TITLE = 'Fussel Gallery' 9 | 10 | 11 | class Config: 12 | 13 | _instance = None 14 | 15 | def __init__(self): 16 | raise RuntimeError('Call instance() instead') 17 | 18 | @classmethod 19 | def instance(cls): 20 | if cls._instance is None: 21 | cls._instance = cls.__new__(cls) 22 | # Put any initialization here. 23 | return cls._instance 24 | 25 | @classmethod 26 | def init(cls, yaml_config): 27 | cls._instance = cls.__new__(cls) 28 | cls._instance.input_photos_dir = str(yaml_config.getKey( 29 | 'gallery.input_path')) 30 | cls._instance.people_enabled = bool(yaml_config.getKey( 31 | 'gallery.people.enable', True)) 32 | cls._instance.watermark_enabled = bool(yaml_config.getKey( 33 | 'gallery.watermark.enable', True)) 34 | cls._instance.watermark_path = str(yaml_config.getKey( 35 | 'gallery.watermark.path', DEFAULT_WATERMARK_PATH)) 36 | cls._instance.watermark_ratio = float(yaml_config.getKey( 37 | 'gallery.watermark.size_ratio', DEFAULT_WATERMARK_SIZE_RATIO)) 38 | cls._instance.recursive_albums = bool(yaml_config.getKey( 39 | 'gallery.albums.recursive', True)) 40 | cls._instance.recursive_albums_name_pattern = str(yaml_config.getKey( 41 | 'gallery.albums.recursive_name_pattern', DEFAULT_RECURSIVE_ALBUMS_NAME_PATTERN)) 42 | cls._instance.overwrite = bool(yaml_config.getKey( 43 | 'gallery.overwrite', False)) 44 | cls._instance.output_photos_path = str(yaml_config.getKey( 45 | 'gallery.output_path', DEFAULT_OUTPUT_PHOTOS_PATH)) 46 | cls._instance.http_root = str( 47 | yaml_config.getKey('site.http_root', '/')) 48 | cls._instance.site_name = str(yaml_config.getKey( 49 | 'site.title', DEFAULT_SITE_TITLE)) 50 | cls._instance.supported_extensions = ('.jpg', '.jpeg', '.gif', '.png') 51 | 52 | _parallel_tasks = os.cpu_count()/2 53 | if _parallel_tasks < 1: 54 | _parallel_tasks = 1 55 | cls._instance.parallel_tasks = int(yaml_config.getKey( 56 | 'gallery.parallel_tasks', _parallel_tasks)) 57 | 58 | cls._instance.exif_transpose = bool( 59 | yaml_config.getKey('gallery.exif_transpose', False)) 60 | 61 | -------------------------------------------------------------------------------- /fussel/generator/generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import shutil 5 | import json 6 | from urllib.parse import quote 7 | from PIL import Image, ImageOps, ImageFile, UnidentifiedImageError 8 | from bs4 import BeautifulSoup 9 | from dataclasses import dataclass 10 | from .config import * 11 | from .util import * 12 | from threading import RLock 13 | from multiprocessing import Pool, Queue 14 | from rich import print 15 | 16 | ImageFile.LOAD_TRUNCATED_IMAGES = True 17 | 18 | 19 | class SimpleEncoder(json.JSONEncoder): 20 | def default(self, obj): 21 | if hasattr(obj, "json_dump_obj"): 22 | return obj.json_dump_obj() 23 | return obj.__dict__ 24 | 25 | 26 | class Site: 27 | 28 | _instance = None 29 | 30 | def __init__(self): 31 | raise RuntimeError('Call instance() instead') 32 | 33 | @classmethod 34 | def instance(cls): 35 | if cls._instance is None: 36 | cls._instance = cls.__new__(cls) 37 | cls._instance.__init() 38 | return cls._instance 39 | 40 | def __init(self): 41 | self.site_name = Config.instance().site_name 42 | self.people_enabled = Config.instance().people_enabled 43 | 44 | 45 | @dataclass 46 | class FaceGeometry: 47 | w: str 48 | h: str 49 | x: str 50 | y: str 51 | 52 | 53 | @dataclass 54 | class Face: 55 | name: str 56 | geometry: FaceGeometry 57 | 58 | 59 | class People: 60 | 61 | _instance = None 62 | 63 | def __init__(self): 64 | raise RuntimeError('Call instance() instead') 65 | 66 | @classmethod 67 | def instance(cls): 68 | if cls._instance is None: 69 | cls._instance = cls.__new__(cls) 70 | cls._instance.__init() 71 | return cls._instance 72 | 73 | def __init(self): 74 | self.people: dict = {} 75 | self.people_lock = RLock() 76 | self.slugs = set() 77 | self.slugs_lock = RLock() 78 | 79 | def json_dump_obj(self): 80 | r = {} 81 | for v in self.people.values(): 82 | r[v.slug] = v 83 | return r 84 | 85 | def detect_faces(self, photo, original_src, largest_src, output_path, external_path): 86 | 87 | print(f'Searching in [magenta]{original_src}[/magenta]...') 88 | faces = self.extract_faces(original_src) 89 | 90 | for face in faces: 91 | print(f' ------> Found: [cyan]{face.name}[/cyan]') 92 | 93 | if face.name not in self.people.keys(): 94 | 95 | unique_person_slug = find_unique_slug( 96 | self.slugs, self.slugs_lock, face.name) 97 | 98 | self.people[face.name] = Person(face.name, unique_person_slug) 99 | 100 | person = self.people[face.name] 101 | person.photos.append(photo) 102 | 103 | if not person.has_thumbnail(): 104 | with Image.open(largest_src) as im: 105 | 106 | face_size = face.geometry.w, face.geometry.h 107 | face_position = face.geometry.x, face.geometry.y 108 | new_face_photo = os.path.join( 109 | output_path, "%s_%s" % (person.slug, os.path.basename(original_src))) 110 | box = calculate_face_crop_dimensions( 111 | im.size, face_size, face_position) 112 | im_cropped = im.crop(box) 113 | im_cropped.save(new_face_photo) 114 | person.src = "%s/%s" % (external_path, 115 | os.path.basename(new_face_photo)) 116 | 117 | return faces 118 | 119 | def extract_faces(self, photo_path): 120 | faces = [] 121 | with Image.open(photo_path) as im: 122 | if hasattr(im, 'applist'): 123 | for segment, content in im.applist: 124 | marker, body = content.split(bytes('\x00', 'utf-8'), 1) 125 | if segment == 'APP1' and marker.decode("utf-8") == 'http://ns.adobe.com/xap/1.0/': 126 | body_str = body.decode("utf-8") 127 | soup = BeautifulSoup(body_str, 'html.parser') 128 | 129 | for regions in soup.find_all("mwg-rs:regions"): 130 | 131 | for regionlist in regions.find_all("mwg-rs:regionlist"): 132 | for description in regionlist.find_all("rdf:description"): 133 | if description['mwg-rs:type'] == 'Face': 134 | name = description['mwg-rs:name'].strip() 135 | areas = description.findChildren( 136 | "mwg-rs:area", recursive=False) 137 | for area in areas: 138 | faces.append(Face( 139 | name=name, 140 | geometry=FaceGeometry( 141 | w=area['starea:w'], 142 | h=area['starea:h'], 143 | x=area['starea:x'], 144 | y=area['starea:y'] 145 | ) 146 | )) 147 | return faces 148 | 149 | 150 | class Person: 151 | 152 | def __init__(self, name, slug): 153 | 154 | self.name = name 155 | self.slug = slug 156 | self.src = None 157 | self.photos: list = [] 158 | 159 | def has_thumbnail(self): 160 | return self.src is not None 161 | 162 | 163 | class Photo: 164 | 165 | def __init__(self, name, width, height, src, thumb, slug, srcSet): 166 | 167 | self.width = width 168 | self.height = height 169 | self.name = name 170 | self.src = src 171 | self.thumb = thumb 172 | self.srcSet = srcSet 173 | self.faces: list = [] 174 | self.slug = slug 175 | 176 | @classmethod 177 | def process_photo(cls, external_path, photo, filename, slug, output_path, people_q: Queue): 178 | new_original_photo = os.path.join( 179 | output_path, "original_%s%s" % (os.path.basename(slug), extract_extension(photo))) 180 | 181 | # Verify original first to avoid PIL errors later when generating thumbnails etc 182 | try: 183 | with Image.open(photo) as im: 184 | im.verify() 185 | # Unfortunately verify only catches a few defective images, this transpose catches more. Verify requires subsequent reopen according to Pillow docs. 186 | with Image.open(photo) as im2: 187 | im2.transpose(Image.FLIP_TOP_BOTTOM) 188 | except Exception as e: 189 | raise PhotoProcessingFailure( 190 | message="Image Verification: " + str(e)) 191 | 192 | # Only copy if overwrite explicitly asked for or if doesn't exist 193 | if Config.instance().overwrite or not os.path.exists(new_original_photo): 194 | print(f' ----> Copying to [magenta]{new_original_photo}[/magenta]') 195 | shutil.copyfile(photo, new_original_photo) 196 | 197 | try: 198 | with Image.open(new_original_photo) as im: 199 | original_size = im.size 200 | width, height = im.size 201 | except UnidentifiedImageError as e: 202 | shutil.rmtree(new_original_photo, ignore_errors=True) 203 | raise PhotoProcessingFailure(message=str(e)) 204 | 205 | # TODO expose to config 206 | sizes = [(500, 500), (800, 800), (1024, 1024), (1600, 1600)] 207 | largest_src = None 208 | smallest_src = None 209 | 210 | srcSet = {} 211 | 212 | msg = " ------> Generating photo sizes: " 213 | for i, size in enumerate(sizes): 214 | new_size = calculate_new_size(original_size, size) 215 | new_sub_photo = os.path.join(output_path, "%sx%s_%s%s" % ( 216 | new_size[0], new_size[1], os.path.basename(slug), extract_extension(photo))) 217 | largest_src = new_sub_photo 218 | if smallest_src is None: 219 | smallest_src = new_sub_photo 220 | 221 | # Only generate if overwrite explicitly asked for or if doesn't exist 222 | msg += f'[cyan]{new_size[0]}x{new_size[1]}[/cyan] ' 223 | if Config.instance().overwrite or not os.path.exists(new_sub_photo): 224 | with Image.open(new_original_photo) as im: 225 | im.thumbnail(new_size) 226 | if Config.instance().exif_transpose: 227 | im = ImageOps.exif_transpose(im) 228 | im.save(new_sub_photo) 229 | srcSet[str(size)+"w"] = ["%s/%s" % (quote(external_path), 230 | quote(os.path.basename(new_sub_photo)))] 231 | 232 | print(msg) 233 | 234 | # Only copy if overwrite explicitly asked for or if doesn't exist 235 | if Config.instance().watermark_enabled and (Config.instance().overwrite or not os.path.exists(new_original_photo)): 236 | with Image.open(Config.instance().watermark_path) as watermark_im: 237 | print(" ------> Adding watermark") 238 | apply_watermark(largest_src, watermark_im, 239 | Config.instance().watermark_ratio) 240 | 241 | photo_obj = Photo( 242 | filename, 243 | width, 244 | height, 245 | "%s/%s" % (quote(external_path), 246 | quote(os.path.basename(largest_src))), 247 | "%s/%s" % (quote(external_path), 248 | quote(os.path.basename(smallest_src))), 249 | slug, 250 | srcSet 251 | ) 252 | 253 | # Faces 254 | if Config.instance().people_enabled: 255 | people_q.put((photo_obj, new_original_photo, 256 | largest_src, output_path, external_path)) 257 | 258 | return photo_obj 259 | 260 | 261 | def _process_photo(t): 262 | (external_path, photo_file, filename, unique_slug, album_folder) = t 263 | print(f' --> Processing [magenta]{photo_file}[/magenta]...') 264 | try: 265 | return (photo_file, Photo.process_photo(external_path, photo_file, filename, unique_slug, album_folder, _process_photo.people_q)) 266 | except PhotoProcessingFailure as e: 267 | print( 268 | f'[yellow]Skipping processing of image file[/yellow] [magenta]{photo_file}[/magenta] Reason: [red]{str(e)}[/red]') 269 | return (photo_file, None) 270 | 271 | 272 | def _proces_photo_init(people_q): 273 | _process_photo.people_q = people_q 274 | 275 | 276 | class Albums: 277 | 278 | _instance = None 279 | 280 | def __init__(self): 281 | raise RuntimeError('Call instance() instead') 282 | 283 | @classmethod 284 | def instance(cls): 285 | if cls._instance is None: 286 | cls._instance = cls.__new__(cls) 287 | cls._instance.__init() 288 | return cls._instance 289 | 290 | def __init(self): 291 | self.albums: dict = {} 292 | self.slugs = set() 293 | self.slugs_lock = RLock() 294 | 295 | def json_dump_obj(self): 296 | return self.albums 297 | 298 | def add_album(self, album): 299 | self.albums[album.slug] = album 300 | 301 | def __getitem__(self, item): 302 | return list(self.albums.values())[item] 303 | 304 | def process_path(self, root_path, output_albums_photos_path, external_root): 305 | 306 | entries = list(map(lambda e: os.path.join( 307 | root_path, e), os.listdir(root_path))) 308 | paths = list(filter(lambda e: is_supported_album(e), entries)) 309 | 310 | for album_path in paths: 311 | album_name = os.path.basename(album_path) 312 | if not album_name.startswith('.'): # skip dotfiles 313 | self.process_album_path( 314 | album_path, album_name, output_albums_photos_path, external_root) 315 | 316 | def process_album_path(self, album_dir, album_name, output_albums_photos_path, external_root): 317 | 318 | unique_album_slug = find_unique_slug( 319 | self.slugs, self.slugs_lock, album_name) 320 | print( 321 | f'Importing [magenta]{album_dir}[/magenta] as [green]{album_name}[/green] ([yellow]{unique_album_slug}[/yellow])') 322 | 323 | album_obj = Album(album_name, unique_album_slug) 324 | 325 | album_name_folder = os.path.basename(unique_album_slug) 326 | album_folder = os.path.join( 327 | output_albums_photos_path, album_name_folder) 328 | # TODO externalize this? 329 | external_path = os.path.join(external_root, album_name_folder) 330 | os.makedirs(album_folder, exist_ok=True) 331 | 332 | entries = list(map(lambda e: os.path.join( 333 | album_dir, e), sorted(os.listdir(album_dir)))) 334 | dirs = list(filter(lambda e: is_supported_album(e), entries)) 335 | files = list(filter(lambda e: is_supported_photo(e), entries)) 336 | 337 | unique_slugs_lock = RLock() 338 | unique_slugs = set() 339 | 340 | jobs = [] 341 | 342 | for album_file in files: 343 | if album_file.startswith('.'): # skip dotfiles 344 | continue 345 | photo_file = os.path.join(album_dir, album_file) 346 | filename = os.path.basename(os.path.basename(photo_file)) 347 | 348 | # Get a unique slug 349 | unique_slug = find_unique_slug( 350 | unique_slugs, unique_slugs_lock, filename) 351 | 352 | jobs.append((external_path, photo_file, 353 | filename, unique_slug, album_folder)) 354 | 355 | results = [] 356 | people_q = Queue() 357 | with Pool(processes=Config.instance().parallel_tasks, initializer=_proces_photo_init, initargs=[people_q]) as P: 358 | results = P.map(_process_photo, jobs) 359 | 360 | people = People.instance() 361 | print(f'Detecting Faces...') 362 | while not people_q.empty(): 363 | (photo_obj, new_original_photo, largest_src, 364 | output_path, external_path) = people_q.get() 365 | people.detect_faces(photo_obj, new_original_photo, 366 | largest_src, output_path, external_path) 367 | 368 | for photo_file, result in results: 369 | if result is not None: 370 | album_obj.add_photo(result) 371 | 372 | if len(album_obj.photos) > 0: 373 | album_obj.src = pick_album_thumbnail( 374 | album_obj.photos) # TODO internalize 375 | self.add_album(album_obj) 376 | 377 | # Recursively process sub-dirs 378 | if Config.instance().recursive_albums: 379 | for sub_album_dir in dirs: 380 | if os.path.basename(sub_album_dir).startswith('.'): # skip dotfiles 381 | continue 382 | sub_album_name = "%s" % Config.instance().recursive_albums_name_pattern 383 | sub_album_name = sub_album_name.replace( 384 | "{parent_album}", unique_album_slug) 385 | sub_album_name = sub_album_name.replace( 386 | "{album}", os.path.basename(sub_album_dir)) 387 | self.process_album_path( 388 | sub_album_dir, sub_album_name, output_albums_photos_path, external_root) 389 | 390 | 391 | class Album: 392 | 393 | def __init__(self, name, slug): 394 | self.name = name 395 | self.slug = slug 396 | self.photos: list = [] 397 | self.src: str = None 398 | 399 | def add_photo(self, photo): 400 | self.photos.append(photo) 401 | 402 | 403 | class SiteGenerator: 404 | 405 | def __init__(self, yaml_config): 406 | 407 | Config.init(yaml_config) 408 | 409 | self.unique_person_slugs = {} 410 | 411 | def generate(self): 412 | 413 | print( 414 | f'[bold]Generating site from [magenta]{Config.instance().input_photos_dir}[magenta][/bold]') 415 | output_photos_path = os.path.normpath(os.path.join(os.path.dirname( 416 | os.path.realpath(__file__)), "..", "web", "public", "static", "_gallery")) 417 | output_data_path = os.path.normpath(os.path.join(os.path.dirname( 418 | os.path.realpath(__file__)), "..", "web", "src", "_gallery")) 419 | external_root = os.path.normpath(os.path.join( 420 | Config.instance().http_root, "static", "_gallery", "albums")) 421 | generated_site_path = os.path.normpath(os.path.join( 422 | os.path.dirname(os.path.realpath(__file__)), "..", "web", "build")) 423 | 424 | # Paths 425 | output_albums_data_file = os.path.join( 426 | output_data_path, "albums_data.js") 427 | output_people_data_file = os.path.join( 428 | output_data_path, "people_data.js") 429 | output_site_data_file = os.path.join(output_data_path, "site_data.js") 430 | output_albums_photos_path = os.path.join(output_photos_path, "albums") 431 | 432 | # Cleanup and prep of deploy space 433 | if Config.instance().overwrite: 434 | shutil.rmtree(output_photos_path, ignore_errors=True) 435 | shutil.rmtree(generated_site_path, ignore_errors=True) 436 | 437 | os.makedirs(output_photos_path, exist_ok=True) 438 | shutil.rmtree(output_data_path, ignore_errors=True) 439 | os.makedirs(output_data_path, exist_ok=True) 440 | 441 | Albums.instance().process_path(Config.instance().input_photos_dir, 442 | output_albums_photos_path, external_root) 443 | 444 | with open(output_albums_data_file, 'w') as outfile: 445 | output_str = 'export const albums_data = ' 446 | output_str += json.dumps(Albums.instance(), 447 | sort_keys=True, indent=3, cls=SimpleEncoder) 448 | output_str += ';' 449 | outfile.write(output_str) 450 | 451 | with open(output_people_data_file, 'w') as outfile: 452 | output_str = 'export const people_data = ' 453 | output_str += json.dumps(People.instance(), 454 | sort_keys=True, indent=3, cls=SimpleEncoder) 455 | output_str += ';' 456 | outfile.write(output_str) 457 | 458 | with open(output_site_data_file, 'w') as outfile: 459 | output_str = 'export const site_data = ' 460 | output_str += json.dumps(Site.instance(), 461 | sort_keys=True, indent=3, cls=SimpleEncoder) 462 | output_str += ';' 463 | outfile.write(output_str) 464 | 465 | 466 | class PhotoProcessingFailure(Exception): 467 | def __init__(self, message="Failed to process photo"): 468 | self.message = message 469 | super().__init__(self.message) 470 | -------------------------------------------------------------------------------- /fussel/generator/util.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from PIL import Image 4 | from slugify import slugify 5 | from .config import * 6 | import os 7 | 8 | 9 | def is_supported_album(path): 10 | folder_name = os.path.basename(path) 11 | return not folder_name.startswith(".") and os.path.isdir(path) 12 | 13 | 14 | def extract_extension(path): 15 | return os.path.splitext(path)[1].lower() 16 | 17 | def is_supported_photo(path): 18 | ext = extract_extension(path) 19 | return ext in Config.instance().supported_extensions 20 | 21 | 22 | def find_unique_slug(slugs, lock, name): 23 | 24 | slug = slugify(name, allow_unicode=False, max_length=0, 25 | word_boundary=True, separator="-", save_order=True) 26 | lock.acquire() 27 | if slug not in slugs: 28 | return slug 29 | count = 1 30 | while True: 31 | new_slug = slug + "-" + str(count) 32 | if new_slug not in slugs: 33 | slug = new_slug 34 | break 35 | count += 1 36 | 37 | slugs.add(slug) 38 | lock.release() 39 | 40 | return slug 41 | 42 | 43 | def calculate_new_size(input_size, desired_size): 44 | if input_size[0] <= desired_size[0]: 45 | return input_size 46 | reduction_factor = input_size[0] / desired_size[0] 47 | return int(input_size[0] / reduction_factor), int(input_size[1] / reduction_factor) 48 | 49 | 50 | def increase_w(left, top, right, bottom, w, h, target_ratio): 51 | # print("increase width") 52 | f_l = left 53 | f_r = right 54 | f_w = f_r - f_l 55 | f_h = bottom - top 56 | next_step_ratio = float((f_w+1)/f_h) 57 | # print("%d/%d = %f = %f" % (f_w, f_h, next_step_ratio, target_ratio)) 58 | while next_step_ratio < target_ratio and f_l-1 > 0 and f_r+1 < w: 59 | f_l -= 1 60 | f_r += 1 61 | f_w = f_r - f_l 62 | next_step_ratio = float((f_w+1)/f_h) 63 | # print("%d/%d = %f = %f" % (f_w, f_h, next_step_ratio, target_ratio)) 64 | return (f_l, top, f_r, bottom) 65 | 66 | 67 | def increase_h(left, top, right, bottom, w, h, target_ratio): 68 | # print("increase height") 69 | f_t = top 70 | f_b = bottom 71 | f_w = right - left 72 | f_h = f_b - f_t 73 | next_step_ratio = float((f_w+1)/f_h) 74 | # print("%d/%d = %f = %f" % (f_w, f_h, next_step_ratio, target_ratio)) 75 | while next_step_ratio > target_ratio and f_t-1 > 0 and f_b+1 < h: 76 | f_t -= 1 77 | f_b += 1 78 | f_w = f_b - f_t 79 | next_step_ratio = float((f_w+1)/f_h) 80 | # print("%d/%d = %f = %f" % (f_w, f_h, next_step_ratio, target_ratio)) 81 | return (left, f_t, right, f_b) 82 | 83 | 84 | def increase_size(left, top, right, bottom, w, h, target_ratio): 85 | # print("increase size") 86 | f_t = top 87 | f_b = bottom 88 | f_l = left 89 | f_r = right 90 | f_w = f_r - f_l 91 | original_f_w = f_r - f_l 92 | f_h = f_b - f_t 93 | # print("%d/%d = %f = %f" % (f_w, f_h, float((f_w + 1) / original_f_w), target_ratio)) 94 | next_step_ratio = float((f_w + 1) / original_f_w) 95 | while next_step_ratio < target_ratio and f_t-1 > 0 and f_b+1 < h and f_l-1 > 0 and f_r+1 < w: 96 | f_t -= 1 97 | f_b += 1 98 | f_l -= 1 99 | f_r += 1 100 | f_w = f_r - f_l 101 | f_h = f_b - f_t 102 | next_step_ratio = float((f_w + 1) / original_f_w) 103 | # print("%d/%d = %f = %f" % (f_w, f_h, float((f_w + 1) / original_f_w), target_ratio)) 104 | return (f_l, f_t, f_r, f_b) 105 | 106 | 107 | def calculate_face_crop_dimensions(input_size, face_size, face_position): 108 | 109 | target_ratio = float(4/3) 110 | target_upsize_ratio = float(2.5) 111 | 112 | x = int(input_size[0] * float(face_position[0])) 113 | y = int(input_size[1] * float(face_position[1])) 114 | w = int(input_size[0] * float(face_size[0])) 115 | h = int(input_size[1] * float(face_size[1])) 116 | 117 | left = x - int(w/2) + 1 118 | right = x + int(w/2) - 1 119 | top = y - int(h/2) + 1 120 | bottom = y + int(h/2) - 1 121 | 122 | # try to increase 123 | if float(right - left + 1 / bottom - top - 1) < target_ratio: # horizontal expansion needed 124 | left, top, right, bottom = increase_w( 125 | left, top, right, bottom, input_size[0], input_size[1], target_ratio) 126 | elif float(right - left + 1 / bottom - top - 1) > target_ratio: # vertical expansion needed 127 | left, top, right, bottom = increase_h( 128 | left, top, right, bottom, input_size[0], input_size[1], target_ratio) 129 | 130 | # attempt to expand photo 131 | left, top, right, bottom = increase_size( 132 | left, top, right, bottom, input_size[0], input_size[1], target_upsize_ratio) 133 | 134 | return left, top, right, bottom 135 | 136 | 137 | def apply_watermark(base_image_path, watermark_image, watermark_ratio): 138 | 139 | with Image.open(base_image_path) as base_image: 140 | width, height = base_image.size 141 | orig_watermark_width, orig_watermark_height = watermark_image.size 142 | watermark_width = int(width * watermark_ratio) 143 | watermark_height = int( 144 | watermark_width/orig_watermark_width * orig_watermark_height) 145 | watermark_image = watermark_image.resize( 146 | (watermark_width, watermark_height)) 147 | transparent = Image.new(base_image.mode, (width, height), (0, 0, 0, 0)) 148 | transparent.paste(base_image, (0, 0)) 149 | 150 | watermark_x = width - watermark_width 151 | watermark_y = height - watermark_height 152 | transparent.paste(watermark_image, box=( 153 | watermark_x, watermark_y), mask=watermark_image) 154 | transparent.save(base_image_path) 155 | 156 | 157 | def pick_album_thumbnail(album_photos): 158 | if len(album_photos) > 0: 159 | return album_photos[0].thumb 160 | return '' 161 | -------------------------------------------------------------------------------- /fussel/requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==10.4.0 2 | beautifulsoup4==4.12.3 3 | massedit==0.70.0 4 | python-slugify==8.0.4 5 | pyyaml==6.0.2 6 | rich==13.9.4 7 | jinja2-cli -------------------------------------------------------------------------------- /fussel/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fussel", 3 | "version": "1.0.0", 4 | "description": "Fussel Gallery", 5 | "keywords": [], 6 | "homepage": "/", 7 | "license": "MIT", 8 | "dependencies": { 9 | "bulma": "^0.9.4", 10 | "react": "^18.3.1", 11 | "react-dom": "^18.3.1", 12 | "react-helmet": "^6.1.0", 13 | "react-modal": "^3.16.1", 14 | "react-responsive-masonry": "^2.6.0", 15 | "react-router-dom": "^6.28.0", 16 | "react-scripts": "^5.0.1", 17 | "swiper": "^9.4.1" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts --openssl-legacy-provider start", 21 | "build": "react-scripts --openssl-legacy-provider build" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /fussel/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/web/public/favicon.ico -------------------------------------------------------------------------------- /fussel/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 |

16 | Powered by Fussel. The source code is licensed MIT 17 |

18 |
19 |
20 | 21 | -------------------------------------------------------------------------------- /fussel/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /static/ 3 | -------------------------------------------------------------------------------- /fussel/web/src/component/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Navbar from "./Navbar"; 3 | import Collections from "./Collections"; 4 | import Collection from "./Collection"; 5 | import NotFound from "./NotFound"; 6 | import { site_data } from "../_gallery/site_data.js" 7 | import { Routes, Route } from "react-router-dom"; 8 | import { Helmet } from "react-helmet"; 9 | 10 | export default class App extends Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | 20 | {site_data['site_name']} 21 | 22 | 23 | }> 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | 29 | {/* Using path="*"" means "match anything", so this route 30 | acts like a catch-all for URLs that we don't have explicit 31 | routes for. */} 32 | } /> 33 | 34 | 35 | 36 | 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /fussel/web/src/component/Collection.css: -------------------------------------------------------------------------------- 1 | /* A fix for an issue I noticed in Safari. 2 | I reported the issue: https://github.com/neptunian/react-photo-gallery/issues/174 3 | Keep an eye on the ticket and maybe one day I can remove this */ 4 | .react-photo-gallery--gallery>div>img { 5 | align-self: flex-start; 6 | } 7 | 8 | .modal-close-button { 9 | position: absolute; 10 | z-index: 100; 11 | right: 15px; 12 | top: 15px; 13 | } 14 | 15 | .gallery-image{ 16 | width: "100%"; 17 | display: "block"; 18 | cursor: pointer; 19 | } 20 | 21 | .footer { 22 | padding: 3rem 1.5rem 6rem; 23 | margin-top: 3rem; 24 | } 25 | 26 | .swiper { 27 | width: 100%; 28 | height: 100%; 29 | } 30 | 31 | .swiper-slide { 32 | text-align: center; 33 | font-size: 18px; 34 | background: #000; 35 | } 36 | 37 | .swiper-slide img { 38 | width: auto; 39 | height: auto; 40 | max-width: 100%; 41 | max-height: 100%; 42 | -ms-transform: translate(-50%, -50%); 43 | -webkit-transform: translate(-50%, -50%); 44 | -moz-transform: translate(-50%, -50%); 45 | transform: translate(-50%, -50%); 46 | position: absolute; 47 | left: 50%; 48 | top: 50%; 49 | } 50 | 51 | .swiper { 52 | width: 100%; 53 | height: 100%; 54 | } 55 | 56 | .ReactModal__Content { 57 | inset: 10px; 58 | padding: 10px; 59 | } 60 | 61 | .ReactModal__Content--after-open { 62 | inset: 10px; 63 | padding: 10px; 64 | } -------------------------------------------------------------------------------- /fussel/web/src/component/Collection.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Masonry, { ResponsiveMasonry } from "react-responsive-masonry" 3 | import withRouter from './withRouter'; 4 | import { albums_data } from "../_gallery/albums_data.js" 5 | import { people_data } from "../_gallery/people_data.js" 6 | import SwiperCore, { Keyboard, Pagination, HashNavigation, Navigation } from "swiper"; 7 | import { Swiper, SwiperSlide } from 'swiper/react'; 8 | import 'swiper/swiper.min.css'; 9 | import 'swiper/css/navigation' 10 | import 'swiper/css/pagination' 11 | import Modal from 'react-modal'; 12 | 13 | import { Link } from "react-router-dom"; 14 | import "./Collection.css"; 15 | 16 | SwiperCore.use([Navigation]); // Not sure why but need this for slide navigation buttons to be clickable 17 | Modal.setAppElement('#app'); 18 | 19 | 20 | class Collection extends Component { 21 | 22 | constructor(props) { 23 | super(props); 24 | this.state = { 25 | viewerIsOpen: true ? this.props.params.image != undefined : false 26 | }; 27 | } 28 | 29 | modalStateTracker = (event) => { 30 | var newPath = event.newURL.split("#", 2) 31 | if (newPath.length < 2) { 32 | return 33 | } 34 | var oldPath = event.oldURL.split("#", 2) 35 | if (oldPath.length < 2) { 36 | return 37 | } 38 | 39 | var closedModalUrl = "/collections/" + this.props.params.collectionType + "/" + this.props.params.collection 40 | 41 | if (this.state.viewerIsOpen) { 42 | if ( 43 | oldPath[1] != closedModalUrl && 44 | newPath[1] == closedModalUrl 45 | ) { 46 | this.setState({ 47 | viewerIsOpen: false 48 | }) 49 | // var page = document.getElementsByTagName('body')[0]; 50 | // page.classList.remove('noscroll'); 51 | } 52 | } 53 | 54 | if (!this.state.viewerIsOpen) { 55 | if ( 56 | oldPath[1] == closedModalUrl && 57 | newPath[1] != closedModalUrl 58 | ) { 59 | this.setState({ 60 | viewerIsOpen: true 61 | }) 62 | // this.props.navigate("/collections/" + this.props.params.collectionType + "/" + this.props.params.collection + "/" + event.target.attributes.slug.value); 63 | // var page = document.getElementsByTagName('body')[0]; 64 | // page.classList.add('noscroll'); 65 | } 66 | } 67 | } 68 | 69 | openModal = (event) => { 70 | 71 | this.props.navigate("/collections/" + this.props.params.collectionType + "/" + this.props.params.collection + "/" + event.target.attributes.slug.value); 72 | this.setState({ 73 | viewerIsOpen: true 74 | }) 75 | // Add listener to detect if the back button was pressed and the modal should be closed 76 | window.addEventListener('hashchange', this.modalStateTracker, false); 77 | // var page = document.getElementsByTagName('body')[0]; 78 | // page.classList.add('noscroll'); 79 | }; 80 | 81 | closeModal = () => { 82 | 83 | this.props.navigate("/collections/" + this.props.params.collectionType + "/" + this.props.params.collection); 84 | this.setState({ 85 | viewerIsOpen: false 86 | }) 87 | // var page = document.getElementsByTagName('body')[0]; 88 | // page.classList.remove('noscroll'); 89 | }; 90 | 91 | title = (collectionType) => { 92 | var titleStr = "Unknown" 93 | if (collectionType == "albums") { 94 | titleStr = "Albums" 95 | } 96 | else if (collectionType == "people") { 97 | titleStr = "People" 98 | } 99 | return titleStr 100 | } 101 | 102 | collection = (collectionType, collection) => { 103 | let data = {} 104 | if (collectionType == "albums") { 105 | data = albums_data 106 | } 107 | else if (collectionType == "people") { 108 | data = people_data 109 | } 110 | if (collection in data) { 111 | return data[collection] 112 | } 113 | return {} 114 | } 115 | 116 | render() { 117 | let collection_data = this.collection(this.props.params.collectionType, this.props.params.collection) 118 | return ( 119 |
120 |
121 |
122 | 133 |
134 |
135 | 138 | 141 | {collection_data["photos"].map((image, i) => ( 142 | {image.name} 151 | ))} 152 | 153 | 154 | 170 | 175 | 189 | { 190 | collection_data["photos"].map(x => 191 | 192 | 193 | 194 | ) 195 | } 196 | 197 | 198 |
199 | ); 200 | } 201 | } 202 | 203 | export default withRouter(Collection) -------------------------------------------------------------------------------- /fussel/web/src/component/Collections.css: -------------------------------------------------------------------------------- 1 | 2 | .subject-photo { 3 | overflow: hidden; 4 | object-fit: cover; 5 | } -------------------------------------------------------------------------------- /fussel/web/src/component/Collections.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { albums_data } from "../_gallery/albums_data.js" 3 | import { people_data } from "../_gallery/people_data.js" 4 | import { Link } from "react-router-dom"; 5 | import "./Collections.css"; 6 | 7 | import withRouter from './withRouter'; 8 | 9 | 10 | class Collections extends Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | this.state = { }; 15 | } 16 | 17 | generateCards = (collectionType, subjects) => { 18 | return ( 19 |
20 | {Object.keys(subjects).map(subject => { return this.generateCard(collectionType, subjects[subject]) })} 21 |
22 | ) 23 | } 24 | 25 | generateCard = (collectionType, subject) => { 26 | return ( 27 | 28 |
29 |
30 |
31 | {subject.name} 32 |
33 |
34 |
35 |
36 |

{subject.name}

37 |

{subject["photos"].length} Photo{subject["photos"].length === 1 ? '' : 's'}

38 |
39 |
40 |
41 | 42 | ); 43 | } 44 | 45 | title = (collectionType) => { 46 | var titleStr = "Unknown" 47 | if (collectionType == "albums") { 48 | titleStr = "Albums" 49 | } 50 | else if (collectionType == "people") { 51 | titleStr = "People" 52 | } 53 | return titleStr 54 | } 55 | 56 | collections = (collectionType) => { 57 | if (collectionType == "albums") { 58 | return albums_data 59 | } 60 | else if (collectionType == "people") { 61 | return people_data 62 | } 63 | return null 64 | } 65 | 66 | render() { 67 | 68 | // If this is the index we dont get this info from the route 69 | // so if we're missing the param we pull it from props which 70 | // will have been passed in manually 71 | if (!("collectionType" in this.props.params)) { 72 | if (this.props.collectionType != null) { 73 | this.props.params.collectionType = this.props.collectionType 74 | } 75 | } 76 | return ( 77 |
78 |
79 |
80 | 88 |
89 |
90 | {this.generateCards(this.props.params.collectionType, this.collections(this.props.params.collectionType))} 91 |
92 | ); 93 | } 94 | } 95 | 96 | export default withRouter(Collections) -------------------------------------------------------------------------------- /fussel/web/src/component/Navbar.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar { 3 | z-index: 0; 4 | } 5 | 6 | .navbar-text { 7 | font-weight: bold; 8 | font-size: 13pt; 9 | } -------------------------------------------------------------------------------- /fussel/web/src/component/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Outlet, NavLink } from "react-router-dom"; 3 | import "./Navbar.css"; 4 | import logo from '../images/animal-track-transparent-2.png'; 5 | 6 | export default class Navbar extends React.Component { 7 | 8 | generateAlbumsButton = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 |   16 | Albums 17 | 18 | 19 | ) 20 | } 21 | 22 | generatePeopleButton = () => { 23 | if (this.props.hasPeople) { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 |   31 | People 32 | 33 | 34 | ) 35 | } 36 | return '' 37 | } 38 | 39 | render() { 40 | return ( 41 |
42 | 57 | 58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /fussel/web/src/component/NotFound.css: -------------------------------------------------------------------------------- 1 | 2 | .message { 3 | font-size: 20pt; 4 | font-weight: bold; 5 | text-align: center; 6 | } -------------------------------------------------------------------------------- /fussel/web/src/component/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import "./NotFound.css"; 3 | import withRouter from './withRouter'; 4 | 5 | class NotFound extends Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = { }; 10 | } 11 | 12 | render() { 13 | return ( 14 |
15 | Not Found 16 |
17 | ); 18 | } 19 | } 20 | 21 | export default withRouter(NotFound) -------------------------------------------------------------------------------- /fussel/web/src/component/fussel.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "../../.." 5 | }, 6 | { 7 | "path": "../../../../nhl-notifier-telegram-bot" 8 | }, 9 | { 10 | "path": "../../../../nhl-score-bot" 11 | }, 12 | { 13 | "path": "../../../../bubblzbot" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /fussel/web/src/component/withRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams, useNavigate } from 'react-router-dom'; 3 | 4 | const withRouter = WrappedComponent => props => { 5 | const params = useParams(); 6 | const navigate = useNavigate(); 7 | 8 | return ( 9 | 14 | ); 15 | }; 16 | 17 | export default withRouter; -------------------------------------------------------------------------------- /fussel/web/src/images/animal-track-transparent-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/web/src/images/animal-track-transparent-2.png -------------------------------------------------------------------------------- /fussel/web/src/images/animal-track-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/web/src/images/animal-track-transparent.png -------------------------------------------------------------------------------- /fussel/web/src/images/animal-track.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/web/src/images/animal-track.jpg -------------------------------------------------------------------------------- /fussel/web/src/images/fussel-watermark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/fussel/web/src/images/fussel-watermark.png -------------------------------------------------------------------------------- /fussel/web/src/index.css: -------------------------------------------------------------------------------- 1 | 2 | .noscroll { 3 | position: fixed!important 4 | } -------------------------------------------------------------------------------- /fussel/web/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./component/App"; 4 | import "./index.css"; 5 | 6 | import { HashRouter } from 'react-router-dom'; 7 | 8 | ReactDOM.createRoot(document.getElementById("app")).render( 9 | 10 | 11 | 12 | 13 | 14 | ); -------------------------------------------------------------------------------- /generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SCRIPT_PATH="${BASH_SOURCE[0]}" 5 | 6 | CURRENT_PATH=$(dirname $SCRIPT_PATH) 7 | cd $CURRENT_PATH/fussel 8 | 9 | source .venv/bin/activate 10 | # Generate site 11 | ./fussel.py 12 | 13 | -------------------------------------------------------------------------------- /resources/animal-track-2.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/resources/animal-track-2.xcf -------------------------------------------------------------------------------- /resources/animal-track.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/resources/animal-track.xcf -------------------------------------------------------------------------------- /resources/fussel-watermark.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cbenning/fussel/dbcd670a1b4c6d2a1b09b740df1d45dec31eb71a/resources/fussel-watermark.xcf -------------------------------------------------------------------------------- /sample_config.yml: -------------------------------------------------------------------------------- 1 | 2 | gallery: 3 | # Path to the root of your photo files 4 | input_path: "/path/to/my/photos" 5 | 6 | # Path to output the site to 7 | # Default: "site/" 8 | output_path: "site/" 9 | 10 | # Setting to True will force a full rebuild of the gallery photos. 11 | # Setting to False will only generate files which are missing or added 12 | # Default: False 13 | overwrite: False 14 | 15 | # The number of parallel processing tasks that will be used 16 | # Default: / 2 17 | # parallel_tasks: 1 18 | 19 | # Attempt to orient the photo based on embedded EXIF data 20 | # Default: exif_transpose: False 21 | exif_transpose: False 22 | 23 | albums: 24 | # Recursively process sub-folders as albums 25 | # Default: recursive: True 26 | recursive: True 27 | 28 | # Pattern for album name creation of sub albums 29 | # Following substitutions are available: 30 | # - {parent_album} will be replaced with the parent album 31 | # - {album} will be replaced with the album name 32 | # Default: "{parent_album} > {album}" 33 | recursive_name_pattern: "{parent_album} > {album}" 34 | 35 | people: 36 | # Face Tag detection. 37 | # Setting to True adds a faces button and virtual albums for detected people 38 | # Setting to False disables the feature 39 | # Default: True 40 | enable: True 41 | 42 | watermark: 43 | # Enable placement of watermark in bottom right corner of photos. 44 | # Default: True 45 | enable: True 46 | 47 | # Specify path to custome watermark 48 | # Should be a transparent png 49 | # Default: "web/src/images/fussel-watermark.png" 50 | path: "web/src/images/fussel-watermark.png" 51 | 52 | # Watermark size ratio (0.0 -> 1.0) 53 | # Default: 0.3 54 | size_ratio: 0.3 55 | 56 | site: 57 | # Set http_root to the root url you intend to host the site. 58 | # If you host this service at http:/// set to http:///'. 59 | # If you host this service at http:///my/photo/album/ set to 'http:///my/photo/album/' 60 | # Include trailing slash 61 | # Default: "/" 62 | http_root: "/" 63 | 64 | # Title shown at the top of the browser tab 65 | # Default: "Fussel Gallery" 66 | title: "Fussel Gallery" 67 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SCRIPT_PATH="${BASH_SOURCE[0]}" 5 | 6 | CURRENT_PATH=$(dirname $SCRIPT_PATH) 7 | cd $CURRENT_PATH/fussel 8 | 9 | /usr/bin/env python -m venv .venv 10 | source .venv/bin/activate 11 | pip install -r requirements.txt 12 | 13 | cd web 14 | yarn install 15 | 16 | --------------------------------------------------------------------------------