mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
create and view image posts
This commit is contained in:
parent
e2b86f3caf
commit
a17b8785d3
15 changed files with 178 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -160,3 +160,4 @@ cython_debug/
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
.idea/
|
.idea/
|
||||||
app/static/*.css.map
|
app/static/*.css.map
|
||||||
|
/app/static/media/
|
||||||
|
|
|
@ -3,19 +3,22 @@ from datetime import date, datetime, timedelta
|
||||||
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
|
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
|
||||||
from flask_login import login_user, logout_user, current_user, login_required
|
from flask_login import login_user, logout_user, current_user, login_required
|
||||||
from flask_babel import _
|
from flask_babel import _
|
||||||
|
from pillow_heif import register_heif_opener
|
||||||
from sqlalchemy import or_, desc
|
from sqlalchemy import or_, desc
|
||||||
|
|
||||||
from app import db, constants
|
from app import db, constants
|
||||||
from app.activitypub.signature import RsaKeys, HttpSignature
|
from app.activitypub.signature import RsaKeys, HttpSignature
|
||||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
|
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
|
||||||
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies, \
|
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies, \
|
||||||
get_comment_branch, post_reply_count
|
get_comment_branch, post_reply_count, ensure_directory_exists
|
||||||
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
|
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
|
||||||
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
|
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
|
||||||
PostReplyVote, PostVote
|
PostReplyVote, PostVote, File
|
||||||
from app.community import bp
|
from app.community import bp
|
||||||
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
|
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
|
||||||
shorten_string, markdown_to_text, domain_from_url
|
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/add_local', methods=['GET', 'POST'])
|
@bp.route('/add_local', methods=['GET', 'POST'])
|
||||||
|
@ -95,7 +98,7 @@ def show_community(community: Community):
|
||||||
|
|
||||||
return render_template('community/community.html', community=community, title=community.title,
|
return render_template('community/community.html', community=community, title=community.title,
|
||||||
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description,
|
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description,
|
||||||
og_image=og_image)
|
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<actor>/subscribe', methods=['GET'])
|
@bp.route('/<actor>/subscribe', methods=['GET'])
|
||||||
|
@ -209,9 +212,48 @@ def add_post(actor):
|
||||||
domain.post_count += 1
|
domain.post_count += 1
|
||||||
post.domain = domain
|
post.domain = domain
|
||||||
elif form.type.data == 'image':
|
elif form.type.data == 'image':
|
||||||
|
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
|
||||||
post.title = form.image_title.data
|
post.title = form.image_title.data
|
||||||
post.type = POST_TYPE_IMAGE
|
post.type = POST_TYPE_IMAGE
|
||||||
# todo: handle file upload
|
uploaded_file = request.files['image_file']
|
||||||
|
if uploaded_file.filename != '':
|
||||||
|
file_ext = os.path.splitext(uploaded_file.filename)[1]
|
||||||
|
if file_ext.lower() not in allowed_extensions or file_ext != validate_image(
|
||||||
|
uploaded_file.stream):
|
||||||
|
abort(400)
|
||||||
|
new_filename = gibberish(15)
|
||||||
|
|
||||||
|
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
|
||||||
|
ensure_directory_exists(directory)
|
||||||
|
|
||||||
|
final_place = os.path.join(directory, new_filename + file_ext)
|
||||||
|
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
|
||||||
|
uploaded_file.save(final_place)
|
||||||
|
|
||||||
|
if file_ext.lower() == '.heic':
|
||||||
|
register_heif_opener()
|
||||||
|
|
||||||
|
# resize if necessary
|
||||||
|
img = Image.open(final_place)
|
||||||
|
img_width = img.width
|
||||||
|
img_height = img.height
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
if img.width > 2000 or img.height > 2000:
|
||||||
|
img.thumbnail((2000, 2000))
|
||||||
|
img.save(final_place)
|
||||||
|
img_width = img.width
|
||||||
|
img_height = img.height
|
||||||
|
img.thumbnail((256, 256))
|
||||||
|
img.save(final_place_thumbnail, format="WebP", quality=93)
|
||||||
|
thumbnail_width = img.width
|
||||||
|
thumbnail_height = img.height
|
||||||
|
|
||||||
|
file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text=form.image_title.data,
|
||||||
|
width=img_width, height=img_height, thumbnail_width=thumbnail_width,
|
||||||
|
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail)
|
||||||
|
post.image = file
|
||||||
|
db.session.add(file)
|
||||||
|
|
||||||
elif form.type.data == 'poll':
|
elif form.type.data == 'poll':
|
||||||
...
|
...
|
||||||
else:
|
else:
|
||||||
|
@ -262,7 +304,7 @@ def show_post(post_id: int):
|
||||||
|
|
||||||
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
|
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
|
||||||
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
|
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
|
||||||
description=description, og_image=og_image)
|
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
|
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
|
||||||
|
|
|
@ -5,6 +5,7 @@ from app import db
|
||||||
from app.models import Community, File, BannedInstances, PostReply
|
from app.models import Community, File, BannedInstances, PostReply
|
||||||
from app.utils import get_request
|
from app.utils import get_request
|
||||||
from sqlalchemy import desc, text
|
from sqlalchemy import desc, text
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def search_for_community(address: str):
|
def search_for_community(address: str):
|
||||||
|
@ -132,3 +133,13 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post
|
||||||
def post_reply_count(post_id) -> int:
|
def post_reply_count(post_id) -> int:
|
||||||
return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'),
|
return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'),
|
||||||
{'post_id': post_id}).scalar()
|
{'post_id': post_id}).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_directory_exists(directory):
|
||||||
|
parts = directory.split('/')
|
||||||
|
rebuild_directory = ''
|
||||||
|
for part in parts:
|
||||||
|
rebuild_directory += part
|
||||||
|
if not os.path.isdir(rebuild_directory):
|
||||||
|
os.mkdir(rebuild_directory)
|
||||||
|
rebuild_directory += '/'
|
||||||
|
|
|
@ -31,6 +31,22 @@ class File(db.Model):
|
||||||
height = db.Column(db.Integer)
|
height = db.Column(db.Integer)
|
||||||
alt_text = db.Column(db.String(256))
|
alt_text = db.Column(db.String(256))
|
||||||
source_url = db.Column(db.String(256))
|
source_url = db.Column(db.String(256))
|
||||||
|
thumbnail_path = db.Column(db.String(255))
|
||||||
|
thumbnail_width = db.Column(db.Integer)
|
||||||
|
thumbnail_height = db.Column(db.Integer)
|
||||||
|
|
||||||
|
def view_url(self):
|
||||||
|
if self.source_url:
|
||||||
|
return self.source_url
|
||||||
|
elif self.file_path:
|
||||||
|
file_path = self.file_path[4:] if self.file_path.startswith('app/') else self.file_path
|
||||||
|
return f"https://{current_app.config['SERVER_NAME']}/{file_path}"
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def thumbnail_url(self):
|
||||||
|
thumbnail_path = self.thumbnail_path[4:] if self.thumbnail_path.startswith('app/') else self.thumbnail_path
|
||||||
|
return f"https://{current_app.config['SERVER_NAME']}/{thumbnail_path}"
|
||||||
|
|
||||||
|
|
||||||
class Community(db.Model):
|
class Community(db.Model):
|
||||||
|
|
|
@ -166,6 +166,10 @@
|
||||||
content: "\e935";
|
content: "\e935";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-camera::before {
|
||||||
|
content: "\e928";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -169,6 +169,10 @@ nav, etc which are used site-wide */
|
||||||
content: "\e935";
|
content: "\e935";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-camera::before {
|
||||||
|
content: "\e928";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -385,6 +389,11 @@ fieldset legend {
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_type_image .post_image img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.voting_buttons {
|
.voting_buttons {
|
||||||
float: right;
|
float: right;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -156,6 +156,15 @@ nav, etc which are used site-wide */
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_type_image {
|
||||||
|
.post_image {
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.voting_buttons {
|
.voting_buttons {
|
||||||
float: right;
|
float: right;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -168,6 +168,10 @@
|
||||||
content: "\e935";
|
content: "\e935";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-camera::before {
|
||||||
|
content: "\e928";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -399,6 +403,10 @@ nav.navbar {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
background-color: #777;
|
background-color: #777;
|
||||||
|
|
|
@ -174,6 +174,10 @@ nav.navbar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
background-color: $dark-grey;
|
background-color: $dark-grey;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% if post.image_id %}
|
{% if post.type == POST_TYPE_IMAGE %}
|
||||||
<div class="col-8">
|
<div class="col post_type_image">
|
||||||
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
|
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
||||||
|
@ -21,15 +21,10 @@
|
||||||
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
|
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
|
||||||
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
||||||
</small></p>
|
</small></p>
|
||||||
</div>
|
<div class="post_image">
|
||||||
<div class="col-4">
|
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text }}"
|
||||||
{% if post.url %}
|
width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
|
||||||
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
|
</div>
|
||||||
width="100" /></a>
|
|
||||||
{% else %}
|
|
||||||
<a href="post.image.source_url"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
|
|
||||||
width="100" /></a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
@ -54,6 +49,12 @@
|
||||||
{{ render_username(post.author) }}
|
{{ render_username(post.author) }}
|
||||||
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
{% if post.image_id %}
|
||||||
|
<div class="post_image">
|
||||||
|
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
|
||||||
|
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,9 +4,10 @@
|
||||||
<div class="row main_row">
|
<div class="row main_row">
|
||||||
<div class="col{% if post.image_id %}-8{% endif %}">
|
<div class="col{% if post.image_id %}-8{% endif %}">
|
||||||
<h3>
|
<h3>
|
||||||
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a>
|
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}
|
||||||
{% if post.type == post_type_link and post.domain_id %}
|
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-camera"> </span>{% endif %}</a>
|
||||||
<a href="{{ post.url }}" rel="nofollow ugc">
|
{% if post.type == POST_TYPE_LINK and post.domain_id %}
|
||||||
|
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank">
|
||||||
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
|
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
|
||||||
</a>
|
</a>
|
||||||
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
|
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
|
||||||
|
@ -15,9 +16,9 @@
|
||||||
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
|
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if post.image_id %}
|
{% if post.image_id %}
|
||||||
<div class="col-4">
|
<div class="col-4 text-right">
|
||||||
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}"
|
<a href="{{ url_for('community.show_post', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
|
||||||
width="100" />
|
width="100" /></a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-8 position-relative main_pane">
|
<div class="col-8 position-relative main_pane">
|
||||||
<h1>{{ _('Create post') }}</h1>
|
<h1>{{ _('Create post') }}</h1>
|
||||||
<form method="post">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{{ form.csrf_token() }}
|
{{ form.csrf_token() }}
|
||||||
{{ render_field(form.communities) }}
|
{{ render_field(form.communities) }}
|
||||||
<nav id="post_type_chooser">
|
<nav id="post_type_chooser">
|
||||||
|
|
11
app/utils.py
11
app/utils.py
|
@ -4,11 +4,11 @@ import math
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import requests
|
import requests
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import requests
|
import requests
|
||||||
import os
|
import os
|
||||||
|
import imghdr
|
||||||
from flask import current_app, json, redirect, url_for
|
from flask import current_app, json, redirect, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
@ -207,6 +207,15 @@ def retrieve_block_list():
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
|
def validate_image(stream):
|
||||||
|
header = stream.read(512)
|
||||||
|
stream.seek(0)
|
||||||
|
format = imghdr.what(None, header)
|
||||||
|
if not format:
|
||||||
|
return None
|
||||||
|
return '.' + (format if format != 'jpeg' else 'jpg')
|
||||||
|
|
||||||
|
|
||||||
def validation_required(func):
|
def validation_required(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def decorated_view(*args, **kwargs):
|
def decorated_view(*args, **kwargs):
|
||||||
|
|
36
migrations/versions/ee45b9ea4a0c_image_thumbnails.py
Normal file
36
migrations/versions/ee45b9ea4a0c_image_thumbnails.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
"""image-thumbnails
|
||||||
|
|
||||||
|
Revision ID: ee45b9ea4a0c
|
||||||
|
Revises: 4a3ca1701711
|
||||||
|
Create Date: 2023-11-27 20:53:29.624833
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ee45b9ea4a0c'
|
||||||
|
down_revision = '4a3ca1701711'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('thumbnail_path', sa.String(length=255), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('thumbnail_width', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('thumbnail_height', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('thumbnail_height')
|
||||||
|
batch_op.drop_column('thumbnail_width')
|
||||||
|
batch_op.drop_column('thumbnail_path')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -22,3 +22,5 @@ boto3==1.28.35
|
||||||
markdown2==2.4.10
|
markdown2==2.4.10
|
||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
flask-caching==2.0.2
|
flask-caching==2.0.2
|
||||||
|
Pillow
|
||||||
|
pillow-heif
|
||||||
|
|
Loading…
Reference in a new issue