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__
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

View file

@ -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

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的收集
[ ] GIF Support
[ ] 處理因為ig媒體畫面比例固定但是使用者圖片畫面比例不固定導致的問題
看要不要幫使用者的媒體填充畫面到正確的比例
[ ] api: ID查IGIDIGID反查ID
[ ] api: 返回錯誤處理紀錄
[ ] Protobuf 重新定義
[ ] 改善Traceback的收集
[ ] 隊列本地檔案存儲
[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 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:

View file

@ -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:

View file

@ -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
}
####################

View file

@ -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,