PictureMaker: new template, multi page and page switching ; Backend: support gif and webp files
This commit is contained in:
parent
443a2da0ea
commit
5baac1e696
8 changed files with 283 additions and 84 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
@ -1,15 +1,16 @@
|
|||
config/config.py
|
||||
backend/db/id2igid.db
|
||||
|
||||
# ignore
|
||||
__pycache__
|
||||
tmp
|
||||
config/session.json
|
||||
config/traceback.json
|
||||
config/config.py
|
||||
backend/db/id2igid.db
|
||||
|
||||
## testing files
|
||||
test
|
||||
testfiles
|
||||
test.py
|
||||
|
||||
# for sljh
|
||||
## for sljh
|
||||
utils/sljh
|
||||
PictureMaker/sljh.py
|
||||
interface/sljh.py
|
||||
|
|
|
@ -1,73 +1,77 @@
|
|||
import datetime
|
||||
import os
|
||||
import hashlib
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
from jinja2 import Template
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from config.config import TMP
|
||||
from config.config import TMP, TZINFO
|
||||
from utils.err import easyExceptionHandler
|
||||
|
||||
# configuration
|
||||
TIMEZONE = 8
|
||||
IMAGE_WIDTH = 1280
|
||||
IMAGE_HEIGHT = 1280
|
||||
IMAGE_WIDTH = 1080
|
||||
IMAGE_HEIGHT = 1350
|
||||
TEMPLATE_DIR = "./PictureMaker/templates"
|
||||
TARGET_COMMENT_SYSTEM = True # set this to False if your platform not have comment system
|
||||
|
||||
def render(text, filename, width=IMAGE_WIDTH, height=IMAGE_HEIGHT, font_size=60, text_color="black", margin=50) -> int:
|
||||
def render(post_context:dict) -> tuple[list[str], int]:
|
||||
err = 0
|
||||
|
||||
browser = None
|
||||
page = None
|
||||
context = None
|
||||
|
||||
fnlist = []
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
# open a browser
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
|
||||
# template
|
||||
template = Template("{{ text }}")
|
||||
processed_text = template.render(text=text)
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
margin: 0;
|
||||
background-size: {width}px {height}px;
|
||||
width: {width}px;
|
||||
height: {height}px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.text-container {{
|
||||
font-size: {font_size}px;
|
||||
color: {text_color};
|
||||
width: calc(100% - {2 * margin}px);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-container">{processed_text}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
page.set_content(html_content)
|
||||
# render template
|
||||
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
||||
template = env.get_template('index.jinja2')
|
||||
main = {
|
||||
"id": post_context["id"],
|
||||
"content": post_context["content"]["text"],
|
||||
"time": post_context["metadata"]["create_time"].astimezone(tz=TZINFO).strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
## (optional) parent article of comment
|
||||
parent = None
|
||||
if TARGET_COMMENT_SYSTEM:
|
||||
from backend.utils import ld_interface
|
||||
parent_id = post_context["metadata"]["parent"]
|
||||
if parent_id is not None:
|
||||
parent_context = ld_interface.inf.get(index=parent_id, media=False)
|
||||
parent = {
|
||||
"id": parent_context["id"],
|
||||
"content": parent_context["content"]["text"]
|
||||
}
|
||||
|
||||
rendered_html = template.render(main=main, parent=parent)
|
||||
#print(rendered_html)
|
||||
|
||||
page.set_content(rendered_html)
|
||||
|
||||
# set window size
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
|
||||
# screenshot
|
||||
page.screenshot(path=filename)
|
||||
page.set_viewport_size({"width": IMAGE_WIDTH, "height": IMAGE_HEIGHT})
|
||||
|
||||
# screen shot
|
||||
time.sleep(0.1)
|
||||
while True:
|
||||
filename = TMP + hashlib.sha512( str(time.time()).encode() ).hexdigest() + ".jpg"
|
||||
filename = os.path.abspath(filename)
|
||||
page.screenshot(path=filename)
|
||||
fnlist.append(filename)
|
||||
|
||||
result = page.evaluate("scrollPage()")
|
||||
if result == 1:
|
||||
break
|
||||
except Exception as e:
|
||||
# exception
|
||||
print(e)
|
||||
easyExceptionHandler(e)
|
||||
err = 1
|
||||
finally:
|
||||
if page:
|
||||
|
@ -80,10 +84,10 @@ def render(text, filename, width=IMAGE_WIDTH, height=IMAGE_HEIGHT, font_size=60,
|
|||
try: browser.close()
|
||||
except: pass
|
||||
|
||||
return err
|
||||
return fnlist, err
|
||||
|
||||
|
||||
def gen(context:dict) -> str | None:
|
||||
def gen(context:dict) -> List[str]:
|
||||
"""
|
||||
Generate a image that has the content of the post in it.
|
||||
|
||||
|
@ -96,16 +100,14 @@ def gen(context:dict) -> str | None:
|
|||
"""
|
||||
|
||||
# data preparation
|
||||
content = context["content"]["text"]
|
||||
# content = context["content"]["text"]
|
||||
|
||||
# generate image
|
||||
filename = TMP + hashlib.sha512( str(time.time()).encode() ).hexdigest() + ".jpg"
|
||||
filename = os.path.abspath(filename)
|
||||
err = render(content, filename, width=IMAGE_WIDTH, height=IMAGE_HEIGHT)
|
||||
files, err = render(context)
|
||||
if err:
|
||||
return None
|
||||
|
||||
return filename
|
||||
return files
|
||||
|
||||
|
||||
def gen_caption(context:dict) -> str:
|
||||
|
@ -119,8 +121,9 @@ def gen_caption(context:dict) -> str:
|
|||
str: caption.
|
||||
"""
|
||||
|
||||
caption = f"""[TCIVS Niming #{context["id"]}]
|
||||
{context["content"]["text"]}
|
||||
caption = f"""中工匿名#{context["id"]} 🔧 {context["content"]["text"]}
|
||||
|
||||
發布匿名貼文 > niming.tcivs.live
|
||||
"""
|
||||
|
||||
return caption
|
159
PictureMaker/templates/index.jinja2
Normal file
159
PictureMaker/templates/index.jinja2
Normal file
|
@ -0,0 +1,159 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
/*align-items: center;
|
||||
justify-content: center;*/
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 1080px;
|
||||
max-height: 1350px;
|
||||
height: 1350px;
|
||||
display: grid;
|
||||
{% if parent is not none %}
|
||||
grid-template-rows: auto 1fr auto;
|
||||
{% else %}
|
||||
grid-template-rows: 1fr auto;
|
||||
{% endif %}
|
||||
background-color: #fff;
|
||||
/*margin: 20px;*/
|
||||
/*padding: 40px;*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* reply info */
|
||||
.replybox {
|
||||
background-color: lightblue;
|
||||
padding: 20px;
|
||||
margin: 40px 40px 0px 40px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 35px;
|
||||
}
|
||||
|
||||
/* content */
|
||||
.contentbox {
|
||||
/*background-color: blue;*/
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
font-size: 45px;
|
||||
{% if parent is not none %}
|
||||
margin: 20px 40px 0px 40px;
|
||||
{% else %}
|
||||
margin: 40px 40px 0px 40px;
|
||||
{% endif %}
|
||||
|
||||
/* hide scroll bar */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
/* content - hide scroll bar */
|
||||
.contentbox::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
|
||||
/* center if not overflow */
|
||||
.container .center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* metadata */
|
||||
.metadatabox {
|
||||
margin: 0px 40px 40px 40px;
|
||||
color: grey;
|
||||
font-size: 35px;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
vertical-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
.metadata-grid .left {
|
||||
text-align: left;
|
||||
}
|
||||
.metadata-grid .center {
|
||||
text-align: center;
|
||||
}
|
||||
.metadata-grid .right {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- reply info -->
|
||||
{% if parent is not none %}
|
||||
<div class="replybox">
|
||||
Re: #{{ parent.id }} {{ parent.content }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- main content -->
|
||||
<div class="contentbox"><a class="content">{{ main.content }}
|
||||
</a></div>
|
||||
|
||||
<!-- metadata -->
|
||||
<div class="metadatabox">
|
||||
<hr/>
|
||||
<div class="metadata-grid">
|
||||
<!-- <div class="left">IG: niming.tcivs</div> -->
|
||||
<div class="left">匿名中工#{{ main.id }}</div>
|
||||
<div class="center">niming.tcivs.live</div>
|
||||
<div class="right">{{ main.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
const contentbox = document.getElementsByClassName("contentbox")[0];
|
||||
var page = 1;
|
||||
|
||||
// check overflow
|
||||
function checkOverflow() {
|
||||
if (contentbox.scrollHeight <= contentbox.clientHeight) {
|
||||
contentbox.classList.add("center");
|
||||
} else {
|
||||
contentbox.classList.remove("center");
|
||||
}
|
||||
}
|
||||
|
||||
// scroll page
|
||||
function scrollPage() {
|
||||
let total_page = Math.ceil(contentbox.scrollHeight / contentbox.clientHeight);
|
||||
if (total_page > 1 && page < total_page) {
|
||||
contentbox.scrollTo(0, contentbox.scrollTop + contentbox.clientHeight - 45);
|
||||
page++;
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Initial check
|
||||
window.addEventListener("load", checkOverflow);
|
||||
// Optional: recheck on resize
|
||||
// window.addEventListener("resize", checkOverflow);
|
||||
</script>
|
11
TODO
11
TODO
|
@ -1,7 +1,14 @@
|
|||
[ ] 改善Traceback的收集
|
||||
[ ] GIF Support
|
||||
[ ] 處理因為ig媒體畫面比例固定,但是使用者圖片畫面比例不固定導致的問題
|
||||
看要不要幫使用者的媒體填充畫面到正確的比例
|
||||
[ ] api: ID查IGID,IGID反查ID
|
||||
[ ] api: 返回錯誤處理紀錄
|
||||
[ ] Protobuf 重新定義
|
||||
[ ] 改善Traceback的收集
|
||||
[ ] 隊列本地檔案存儲
|
||||
|
||||
[V] 本地儲存ID對IGID表
|
||||
[V] Webp Support
|
||||
[V] GIF Support
|
||||
[V] 使用者文章太長,溢出換頁機制
|
||||
|
||||
[ ] 測試
|
|
@ -11,8 +11,12 @@ from backend.utils import ld_interface
|
|||
from backend.utils import ld_picturemaker
|
||||
from backend.utils import fileProcessor
|
||||
|
||||
def clean(file_list):
|
||||
def clean(file_list:list[str]):
|
||||
for f in file_list:
|
||||
# instagram thumbnail file
|
||||
if f.endswith(".mp4"):
|
||||
try: os.remove(f+".jpg")
|
||||
except: pass
|
||||
try: os.remove(f)
|
||||
except: pass
|
||||
|
||||
|
@ -39,11 +43,11 @@ def upload(aid:int) -> Tuple[str, int]:
|
|||
article["content"]["media"] = []
|
||||
|
||||
# 合成文字圖
|
||||
proma_file = ld_picturemaker.picture_maker.gen(article)
|
||||
if proma_file is None:
|
||||
proma_file:list = ld_picturemaker.picture_maker.gen(article)
|
||||
if len(proma_file) == 0: # no file returned
|
||||
clean(tmp_path)
|
||||
return "Error while generating proma_file", 1
|
||||
tmp_path = [proma_file] + tmp_path
|
||||
tmp_path = proma_file + tmp_path
|
||||
|
||||
# 送交 IG 上傳
|
||||
if not DEBUG:
|
||||
|
|
|
@ -14,6 +14,8 @@ from utils.err import easyExceptionHandler
|
|||
|
||||
register_heif_opener()
|
||||
|
||||
# converters
|
||||
## image
|
||||
def image_conventer(filename:str, binary: bytes) -> int:
|
||||
try:
|
||||
fio = io.BytesIO(binary)
|
||||
|
@ -25,7 +27,7 @@ def image_conventer(filename:str, binary: bytes) -> int:
|
|||
easyExceptionHandler(e)
|
||||
return 1
|
||||
|
||||
|
||||
## video (and gif)
|
||||
def read_output(pipe, q):
|
||||
""" 用於非阻塞讀取 ffmpeg 的 stdout """
|
||||
while True:
|
||||
|
@ -44,12 +46,27 @@ def video_conventor(filename:str, oriFormat:str, binary:bytes) -> int:
|
|||
f.write(binary)
|
||||
|
||||
# ffmpeg process
|
||||
process:subprocess.Popen = (
|
||||
ffmpeg
|
||||
.input(tmpfile, format=oriFormat)
|
||||
.output(filename, format='mp4')
|
||||
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True)
|
||||
)
|
||||
if oriFormat == "gif": # gif process
|
||||
process:subprocess.Popen = (
|
||||
ffmpeg
|
||||
.input(tmpfile, format='gif')
|
||||
.output(
|
||||
filename, format='mp4',
|
||||
movflags='faststart',
|
||||
pix_fmt='yuv420p',
|
||||
vf='scale=trunc(iw/2)*2:trunc(ih/2)*2,fps=15',
|
||||
crf=28,
|
||||
preset='medium'
|
||||
)
|
||||
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True)
|
||||
)
|
||||
else:
|
||||
process:subprocess.Popen = (
|
||||
ffmpeg
|
||||
.input(tmpfile, format=oriFormat)
|
||||
.output(filename, format='mp4')
|
||||
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True)
|
||||
)
|
||||
|
||||
process.wait()
|
||||
|
||||
|
@ -60,13 +77,13 @@ def video_conventor(filename:str, oriFormat:str, binary:bytes) -> int:
|
|||
except Exception as e:
|
||||
easyExceptionHandler(e)
|
||||
return 1
|
||||
|
||||
|
||||
# file_writer
|
||||
def file_writer(filename:str, binary:bytes):
|
||||
with open(filename, "wb") as f:
|
||||
f.write(binary)
|
||||
|
||||
|
||||
# main
|
||||
def file_saver(ftype:str, binary:bytes) -> Tuple[str, int]:
|
||||
"""
|
||||
ftype -> minetype
|
||||
|
@ -87,13 +104,13 @@ def file_saver(ftype:str, binary:bytes) -> Tuple[str, int]:
|
|||
if not ( ftype == "image/jpg" or ftype == "image/webp" or \
|
||||
ftype == "video/mp4" ):
|
||||
# 轉圖片
|
||||
if ftype.startswith("image"):
|
||||
if ftype.startswith("image") and ftype != "image/gif":
|
||||
opt = os.path.abspath(os.path.join(TMP, filename+".jpg"))
|
||||
err = image_conventer(opt, binary)
|
||||
if err: # 發生錯誤
|
||||
return "File convert error", 1
|
||||
# 轉影片
|
||||
elif ftype.startswith("video"):
|
||||
elif ftype.startswith("video") or ftype == "image/gif":
|
||||
opt = os.path.abspath(os.path.join(TMP, filename+".mp4"))
|
||||
err = video_conventor(opt, ext, binary)
|
||||
if err:
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import datetime
|
||||
####################
|
||||
# General config #
|
||||
####################
|
||||
TMP = "./tmp/"
|
||||
TIMEZONE = +8
|
||||
TZINFO = datetime.timezone(datetime.timedelta(hours=TIMEZONE))
|
||||
|
||||
####################
|
||||
# Frontend config #
|
||||
|
@ -39,6 +42,7 @@ FILE_MINE_TYPE = {
|
|||
"video/mp4": "mp4",
|
||||
"video/quicktime": "mov",
|
||||
"video/hevc": "hevc",
|
||||
"image/gif": "gif" # convert gif to mp4
|
||||
}
|
||||
|
||||
####################
|
||||
|
|
|
@ -26,32 +26,34 @@ def get(index:int, media:bool=True) -> dict | None:
|
|||
# get data
|
||||
## fake server in localhost
|
||||
### where is the api of web application?
|
||||
res = requests.get("http://localhost:5000/article/%d?media_count=1"%index)
|
||||
res = requests.get("http://localhost:5000/article/%d?media_count=2"%index)
|
||||
if res.status_code != 200:
|
||||
return None
|
||||
|
||||
rj = res.json()
|
||||
media = []
|
||||
media_arr = []
|
||||
|
||||
# get media
|
||||
if media:
|
||||
for m in rj["media"]:
|
||||
_m = requests.get(m)
|
||||
if _m.status_code == 200:
|
||||
media.append(io.BytesIO(_m.content))
|
||||
media_arr.append(io.BytesIO(_m.content))
|
||||
|
||||
# return
|
||||
result = {
|
||||
"id": rj["id"],
|
||||
"metadata": {
|
||||
"create_time": rj["create_time"],
|
||||
"create_time": datetime.datetime.fromtimestamp(timestamp=rj["create_time"]),
|
||||
"author": "",
|
||||
"tags": [],
|
||||
"category": ""
|
||||
"category": "",
|
||||
# ext
|
||||
"parent": rj["parent"] # parent id
|
||||
},
|
||||
"content": {
|
||||
"text": rj["content"],
|
||||
"media": media
|
||||
"media": media_arr
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,7 +67,9 @@ an_example_of_context = {
|
|||
"create_time": int | datetime.datetime,
|
||||
"author": str,
|
||||
"tags": list[str],
|
||||
"category": str
|
||||
"category": str,
|
||||
# ext
|
||||
"parent": int | None # parent id
|
||||
},
|
||||
"content": {
|
||||
"text": str,
|
||||
|
|
Loading…
Add table
Reference in a new issue