PictureMaker: new template, multi page and page switching ; Backend: support gif and webp files

This commit is contained in:
p23 2025-05-09 21:20:07 +08:00
parent 443a2da0ea
commit 5baac1e696
8 changed files with 283 additions and 84 deletions

11
.gitignore vendored
View file

@ -1,15 +1,16 @@
config/config.py
backend/db/id2igid.db
# ignore
__pycache__ __pycache__
tmp tmp
config/session.json config/session.json
config/traceback.json config/traceback.json
config/config.py ## testing files
backend/db/id2igid.db
test test
testfiles testfiles
test.py test.py
## for sljh
# for sljh
utils/sljh utils/sljh
PictureMaker/sljh.py PictureMaker/sljh.py
interface/sljh.py interface/sljh.py

View file

@ -1,73 +1,77 @@
import datetime
import os import os
import hashlib import hashlib
import time import time
from typing import List
from playwright.sync_api import sync_playwright 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 # configuration
TIMEZONE = 8 IMAGE_WIDTH = 1080
IMAGE_WIDTH = 1280 IMAGE_HEIGHT = 1350
IMAGE_HEIGHT = 1280 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 err = 0
browser = None browser = None
page = None page = None
context = None context = None
fnlist = []
try: try:
with sync_playwright() as p: with sync_playwright() as p:
# open a browser
browser = p.chromium.launch(headless=True) browser = p.chromium.launch(headless=True)
context = browser.new_context() context = browser.new_context()
page = context.new_page() page = context.new_page()
# template # render template
template = Template("{{ text }}") env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
processed_text = template.render(text=text) template = env.get_template('index.jinja2')
html_content = f""" main = {
<html> "id": post_context["id"],
<head> "content": post_context["content"]["text"],
<style> "time": post_context["metadata"]["create_time"].astimezone(tz=TZINFO).strftime("%Y-%m-%d %H:%M:%S")
body {{ }
margin: 0; ## (optional) parent article of comment
background-size: {width}px {height}px; parent = None
width: {width}px; if TARGET_COMMENT_SYSTEM:
height: {height}px; from backend.utils import ld_interface
display: flex; parent_id = post_context["metadata"]["parent"]
justify-content: center; if parent_id is not None:
align-items: center; parent_context = ld_interface.inf.get(index=parent_id, media=False)
overflow: hidden; parent = {
}} "id": parent_context["id"],
.text-container {{ "content": parent_context["content"]["text"]
font-size: {font_size}px; }
color: {text_color};
width: calc(100% - {2 * margin}px); rendered_html = template.render(main=main, parent=parent)
text-align: center; #print(rendered_html)
line-height: 1.2;
white-space: pre-wrap; page.set_content(rendered_html)
overflow-wrap: break-word;
}}
</style>
</head>
<body>
<div class="text-container">{processed_text}</div>
</body>
</html>
"""
page.set_content(html_content)
# set window size # set window size
page.set_viewport_size({"width": width, "height": height}) page.set_viewport_size({"width": IMAGE_WIDTH, "height": IMAGE_HEIGHT})
# screenshot # screen shot
page.screenshot(path=filename) 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: except Exception as e:
# exception # exception
print(e) easyExceptionHandler(e)
err = 1 err = 1
finally: finally:
if page: if page:
@ -80,10 +84,10 @@ def render(text, filename, width=IMAGE_WIDTH, height=IMAGE_HEIGHT, font_size=60,
try: browser.close() try: browser.close()
except: pass 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. Generate a image that has the content of the post in it.
@ -96,16 +100,14 @@ def gen(context:dict) -> str | None:
""" """
# data preparation # data preparation
content = context["content"]["text"] # content = context["content"]["text"]
# generate image # generate image
filename = TMP + hashlib.sha512( str(time.time()).encode() ).hexdigest() + ".jpg" files, err = render(context)
filename = os.path.abspath(filename)
err = render(content, filename, width=IMAGE_WIDTH, height=IMAGE_HEIGHT)
if err: if err:
return None return None
return filename return files
def gen_caption(context:dict) -> str: def gen_caption(context:dict) -> str:
@ -119,8 +121,9 @@ def gen_caption(context:dict) -> str:
str: caption. str: caption.
""" """
caption = f"""[TCIVS Niming #{context["id"]}] caption = f"""中工匿名#{context["id"]} 🔧 {context["content"]["text"]}
{context["content"]["text"]}
發布匿名貼文 > niming.tcivs.live
""" """
return caption return caption

View 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
View file

@ -1,7 +1,14 @@
[ ] 改善Traceback的收集 [ ] 處理因為ig媒體畫面比例固定但是使用者圖片畫面比例不固定導致的問題
[ ] GIF Support 看要不要幫使用者的媒體填充畫面到正確的比例
[ ] api: ID查IGIDIGID反查ID [ ] api: ID查IGIDIGID反查ID
[ ] api: 返回錯誤處理紀錄 [ ] api: 返回錯誤處理紀錄
[ ] Protobuf 重新定義
[ ] 改善Traceback的收集
[ ] 隊列本地檔案存儲
[V] 本地儲存ID對IGID表 [V] 本地儲存ID對IGID表
[V] Webp Support
[V] GIF Support
[V] 使用者文章太長,溢出換頁機制
[ ] 測試 [ ] 測試

View file

@ -11,8 +11,12 @@ from backend.utils import ld_interface
from backend.utils import ld_picturemaker from backend.utils import ld_picturemaker
from backend.utils import fileProcessor from backend.utils import fileProcessor
def clean(file_list): def clean(file_list:list[str]):
for f in file_list: for f in file_list:
# instagram thumbnail file
if f.endswith(".mp4"):
try: os.remove(f+".jpg")
except: pass
try: os.remove(f) try: os.remove(f)
except: pass except: pass
@ -39,11 +43,11 @@ def upload(aid:int) -> Tuple[str, int]:
article["content"]["media"] = [] article["content"]["media"] = []
# 合成文字圖 # 合成文字圖
proma_file = ld_picturemaker.picture_maker.gen(article) proma_file:list = ld_picturemaker.picture_maker.gen(article)
if proma_file is None: if len(proma_file) == 0: # no file returned
clean(tmp_path) clean(tmp_path)
return "Error while generating proma_file", 1 return "Error while generating proma_file", 1
tmp_path = [proma_file] + tmp_path tmp_path = proma_file + tmp_path
# 送交 IG 上傳 # 送交 IG 上傳
if not DEBUG: if not DEBUG:

View file

@ -14,6 +14,8 @@ from utils.err import easyExceptionHandler
register_heif_opener() register_heif_opener()
# converters
## image
def image_conventer(filename:str, binary: bytes) -> int: def image_conventer(filename:str, binary: bytes) -> int:
try: try:
fio = io.BytesIO(binary) fio = io.BytesIO(binary)
@ -25,7 +27,7 @@ def image_conventer(filename:str, binary: bytes) -> int:
easyExceptionHandler(e) easyExceptionHandler(e)
return 1 return 1
## video (and gif)
def read_output(pipe, q): def read_output(pipe, q):
""" 用於非阻塞讀取 ffmpeg 的 stdout """ """ 用於非阻塞讀取 ffmpeg 的 stdout """
while True: while True:
@ -44,12 +46,27 @@ def video_conventor(filename:str, oriFormat:str, binary:bytes) -> int:
f.write(binary) f.write(binary)
# ffmpeg process # ffmpeg process
process:subprocess.Popen = ( if oriFormat == "gif": # gif process
ffmpeg process:subprocess.Popen = (
.input(tmpfile, format=oriFormat) ffmpeg
.output(filename, format='mp4') .input(tmpfile, format='gif')
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True) .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() process.wait()
@ -60,13 +77,13 @@ def video_conventor(filename:str, oriFormat:str, binary:bytes) -> int:
except Exception as e: except Exception as e:
easyExceptionHandler(e) easyExceptionHandler(e)
return 1 return 1
# file_writer
def file_writer(filename:str, binary:bytes): def file_writer(filename:str, binary:bytes):
with open(filename, "wb") as f: with open(filename, "wb") as f:
f.write(binary) f.write(binary)
# main
def file_saver(ftype:str, binary:bytes) -> Tuple[str, int]: def file_saver(ftype:str, binary:bytes) -> Tuple[str, int]:
""" """
ftype -> minetype 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 \ if not ( ftype == "image/jpg" or ftype == "image/webp" or \
ftype == "video/mp4" ): 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")) opt = os.path.abspath(os.path.join(TMP, filename+".jpg"))
err = image_conventer(opt, binary) err = image_conventer(opt, binary)
if err: # 發生錯誤 if err: # 發生錯誤
return "File convert error", 1 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")) opt = os.path.abspath(os.path.join(TMP, filename+".mp4"))
err = video_conventor(opt, ext, binary) err = video_conventor(opt, ext, binary)
if err: if err:

View file

@ -1,7 +1,10 @@
import datetime
#################### ####################
# General config # # General config #
#################### ####################
TMP = "./tmp/" TMP = "./tmp/"
TIMEZONE = +8
TZINFO = datetime.timezone(datetime.timedelta(hours=TIMEZONE))
#################### ####################
# Frontend config # # Frontend config #
@ -39,6 +42,7 @@ FILE_MINE_TYPE = {
"video/mp4": "mp4", "video/mp4": "mp4",
"video/quicktime": "mov", "video/quicktime": "mov",
"video/hevc": "hevc", "video/hevc": "hevc",
"image/gif": "gif" # convert gif to mp4
} }
#################### ####################

View file

@ -26,32 +26,34 @@ def get(index:int, media:bool=True) -> dict | None:
# get data # get data
## fake server in localhost ## fake server in localhost
### where is the api of web application? ### 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: if res.status_code != 200:
return None return None
rj = res.json() rj = res.json()
media = [] media_arr = []
# get media # get media
if media: if media:
for m in rj["media"]: for m in rj["media"]:
_m = requests.get(m) _m = requests.get(m)
if _m.status_code == 200: if _m.status_code == 200:
media.append(io.BytesIO(_m.content)) media_arr.append(io.BytesIO(_m.content))
# return # return
result = { result = {
"id": rj["id"], "id": rj["id"],
"metadata": { "metadata": {
"create_time": rj["create_time"], "create_time": datetime.datetime.fromtimestamp(timestamp=rj["create_time"]),
"author": "", "author": "",
"tags": [], "tags": [],
"category": "" "category": "",
# ext
"parent": rj["parent"] # parent id
}, },
"content": { "content": {
"text": rj["content"], "text": rj["content"],
"media": media "media": media_arr
} }
} }
@ -65,7 +67,9 @@ an_example_of_context = {
"create_time": int | datetime.datetime, "create_time": int | datetime.datetime,
"author": str, "author": str,
"tags": list[str], "tags": list[str],
"category": str "category": str,
# ext
"parent": int | None # parent id
}, },
"content": { "content": {
"text": str, "text": str,