Emotes: one emote, one file

このコミットが含まれているのは:
n9k 2022-07-20 06:04:55 +00:00
コミット 208ef9abc7
11個のファイルの変更31行の追加128行の削除

ファイルの表示

@ -9,7 +9,7 @@ from quart_compress import Compress
from anonstream.config import update_flask_from_toml
from anonstream.quart import Quart
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
from anonstream.utils.chat import schema_to_emotes
from anonstream.utils.chat import precompute_emote_regex
from anonstream.utils.user import generate_blank_allowedness
__version__ = '1.5.5'
@ -47,9 +47,11 @@ def create_app(toml_config):
app.failures = OrderedDict() # access captcha failures
app.allowedness = generate_blank_allowedness()
# Read emote schema
with open(app.config['EMOTE_SCHEMA']) as fp:
schema = json.load(fp)
app.emotes = schema_to_emotes(schema)
emotes = json.load(fp)
precompute_emote_regex(emotes)
app.emotes = emotes
# State for tasks
app.users_update_buffer = set()

ファイルの表示

@ -169,6 +169,5 @@ def toml_to_flask_section_nojs(config):
def toml_to_flask_section_emote(config):
cfg = config['emote']
return {
'EMOTE_SHEET': cfg['sheet'],
'EMOTE_SCHEMA': cfg['schema'],
}

ファイルの表示

@ -2,9 +2,10 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import hashlib
from functools import lru_cache
import markupsafe
from quart import current_app, escape, Markup
from quart import current_app, escape, url_for, Markup
CONFIG = current_app.config
EMOTES = current_app.emotes
@ -19,24 +20,21 @@ def get_scrollback(messages):
return messages
return list(messages)[-n:]
@lru_cache
def get_emote_markup(emote_name, emote_file, emote_width, emote_height):
emote_name_markup = escape(emote_name)
return Markup(
f'''<img class="emote" '''
f'''src="{url_for('static', filename=emote_file)}" '''
f'''width="{escape(emote_width)}" height="{escape(emote_height)}" '''
f'''alt="{emote_name_markup}" title="{emote_name_markup}">'''
)
def insert_emotes(markup):
assert isinstance(markup, markupsafe.Markup)
for name, regex, _position, _size in EMOTES:
emote_markup = (
f'<span class="emote" data-emote="{escape(name)}" '
f'title="{escape(name)}">{escape(name)}</span>'
for emote in EMOTES:
emote_markup = get_emote_markup(
emote['name'], emote['file'], emote['width'], emote['height'],
)
markup = regex.sub(emote_markup, markup)
markup = emote['regex'].sub(emote_markup, markup)
return Markup(markup)
def get_emotes_for_websocket():
return {
name: {
'x': position[0],
'y': position[1],
'width': size[0],
'height': size[1],
}
for name, _regex, position, size in EMOTES
}
return tuple(EMOTES.values())

ファイルの表示

@ -135,7 +135,7 @@ async def access(timestamp, user_or_token):
url = url_for('home', token=user['token'])
return redirect(url, 303)
@current_app.route('/static/<filename>')
@current_app.route('/static/<path:filename>')
@with_user_from(request)
@etag_conditional
@clean_cache_headers

ファイルの表示

@ -10,13 +10,12 @@ from anonstream.user import add_state, pop_state, try_change_appearance, update_
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name
from anonstream.utils.chat import generate_nonce, escape_css_string, get_emotehash
from anonstream.utils.chat import generate_nonce
from anonstream.utils.security import generate_csp
from anonstream.utils.user import concatenate_for_notice
CONFIG = current_app.config
USERS_BY_TOKEN = current_app.users_by_token
EMOTES = current_app.emotes
@current_app.route('/stream.html')
@with_user_from(request)
@ -54,12 +53,8 @@ async def nojs_chat_messages(timestamp, user):
refresh=CONFIG['NOJS_REFRESH_MESSAGES'],
user=user,
users_by_token=USERS_BY_TOKEN,
emotes=EMOTES,
emotesheet=CONFIG['EMOTE_SHEET'],
emotehash=get_emotehash(tuple(EMOTES)),
messages=get_scrollback(current_app.messages),
timeout=CONFIG['NOJS_TIMEOUT_CHAT'],
escape_css_string=escape_css_string,
get_default_name=get_default_name,
)

ファイルの表示

@ -84,12 +84,6 @@ const insert_jsmarkup = () => {
style_tripcode_colors.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_tripcode_colors);
}
if (document.getElementById("style-emote") === null) {
const style_emote = document.createElement("style");
style_emote.id = "style-emote";
style_emote.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_emote);
}
if (document.getElementById("stream__video") === null) {
const parent = document.getElementById("stream");
parent.insertAdjacentHTML("beforeend", jsmarkup_stream_video);
@ -140,7 +134,6 @@ insert_jsmarkup();
const stylesheet_color = document.styleSheets[1];
const stylesheet_tripcode_display = document.styleSheets[2];
const stylesheet_tripcode_colors = document.styleSheets[3];
const stylesheet_emote = document.styleSheets[4];
/* override chat form notice button */
const chat_form = document.getElementById("chat-form_js");
@ -305,39 +298,11 @@ const escape_css_string = (string) => {
}
return result.join("");
}
const update_emotes = async (emotes) => {
const rules = [];
for (const key of Object.keys(emotes)) {
const emote = emotes[key];
rules.push(
`[data-emote="${escape_css_string(key)}"] { background-position: ${-emote.x}px ${-emote.y}px; width: ${emote.width}px; height: ${emote.height}px; }`
);
}
rules.sort();
const emotehash = await hexdigest(rules.toString(), 6);
const emotehash_rule = `.emote { background-image: url("/static/${escape_css_string(escape(emotesheet))}?coords=${escape_css_string(encodeURIComponent(emotehash))}"); }`;
const rules_set = new Set([emotehash_rule, ...rules]);
const to_delete = [];
for (let index = 0; index < stylesheet_emote.cssRules.length; index++) {
const css_rule = stylesheet_emote.cssRules[index];
if (!rules_set.delete(css_rule.cssText)) {
to_delete.push(index);
}
}
for (const rule of rules_set) {
stylesheet_emote.insertRule(rule);
}
for (const index of to_delete.reverse()) {
stylesheet_emote.deleteRule(index + rules_set.size);
}
}
let users = {};
let stats = null;
let stats_received = null;
let default_name = {true: "Broadcaster", false: "Anonymous"};
let emotesheet = "emotes.png";
let max_chat_scrollback = 256;
let pingpong_period = 8.0;
let ping = null;
@ -735,10 +700,6 @@ const on_websocket_message = async (event) => {
chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]);
chat_appearance_form_color.setAttribute("value", user.color);
// emotes
emotesheet = receipt.emotesheet;
await update_emotes(receipt.emotes);
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
const last_seq = last === null ? null : parseInt(last.dataset.seq);

ファイルの表示

@ -264,10 +264,7 @@ noscript {
line-height: 1.3125;
}
.emote {
display: inline-block;
font-size: 0;
vertical-align: middle;
cursor: default;
}
.tripcode {
padding: 0 5px;

ファイルの表示

@ -134,11 +134,7 @@
line-height: 1.3125;
}
.emote {
background-image: url("{{ escape_css_string(url_for('static', filename=emotesheet, coords=emotehash)) | safe }}");
display: inline-block;
font-size: 0;
vertical-align: middle;
cursor: default;
}
.tripcode {
padding: 0 5px;
@ -161,14 +157,6 @@
{% endif %}
{% endwith %}
{% endfor %}
{% for name, _regex, (x, y), (width, height) in emotes %}
[data-emote="{{ escape_css_string(name) | safe }}"] {
background-position: {{ -x }}px {{ -y }}px;
width: {{ width }}px;
height: {{ height }}px;
}
{% endfor %}
</style>
</head>
<body>

ファイルの表示

@ -31,52 +31,19 @@ def get_approx_linespan(text):
linespan = linespan if linespan > 0 else 1
return linespan
def schema_to_emotes(schema):
emotes = []
for name, coords in schema.items():
def precompute_emote_regex(schema):
for emote in schema:
assert emote['name'], 'emote names cannot be empty'
assert not re.search(r'\s', name), \
assert not re.search(r'\s', emote['name']), \
'whitespace is not allowed in emote names'
name_markup = escape(name)
# If the emote name begins with a word character [a-zA-Z0-9_],
# match only if preceded by a non-word character or the empty
# string. Similarly for the end of the emote name.
# Examples:
# * ":joy:" matches "abc :joy:~xyz" and "abc:joy:xyz"
# * "JoySi" matches "abc JoySi~xyz" but NOT "abcJoySiabc"
onset = r'(?:^|(?<=\W))' if re.fullmatch(r'\w', name[0]) else r''
finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', name[-1]) else r''
regex = re.compile(''.join((onset, re.escape(name_markup), finish)))
position, size = tuple(coords['position']), tuple(coords['size'])
emotes.append((name, regex, position, size))
return emotes
def escape_css_string(string):
'''
https://drafts.csswg.org/cssom/#common-serializing-idioms
'''
result = []
for char in string:
if char == '\0':
result.append('\ufffd')
elif char < '\u0020' or char == '\u007f':
result.append(f'\\{ord(char):x}')
elif char == '"' or char == '\\':
result.append(f'\\{char}')
else:
result.append(char)
return ''.join(result)
@lru_cache(maxsize=1)
def get_emotehash(emotes):
rules = []
for name, _regex, (x, y), (width, height) in sorted(emotes):
rule = (
f'[data-emote="{escape_css_string(name)}"] '
f'{{ background-position: {-x}px {-y}px; '
f'width: {width}px; height: {height}px; }}'
)
rules.append(rule.encode())
plaintext = b','.join(rules)
digest = hashlib.sha256(plaintext).digest()
return digest[:6].hex()
onset = r'(?:^|(?<=\W))' if re.fullmatch(r'\w', emote['name'][0]) else r''
finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', emote['name'][-1]) else r''
emote['regex'] = re.compile(''.join(
(onset, re.escape(escape(emote['name'])), finish)
))

ファイルの表示

@ -11,7 +11,6 @@ from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance, ensure_allowedness, AllowednessException
from anonstream.wrappers import with_timestamp, get_timestamp
from anonstream.helpers.chat import get_emotes_for_websocket
from anonstream.utils.chat import generate_nonce
from anonstream.utils.user import identifying_string
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
@ -37,8 +36,6 @@ async def websocket_outbound(queue, user):
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
'digest': get_random_captcha_digest_for(user),
'pingpong': CONFIG['TASK_BROADCAST_PING'],
'emotes': get_emotes_for_websocket(),
'emotesheet': CONFIG['EMOTE_SHEET'],
})
while True:
payload = await queue.get()

ファイルの表示

@ -91,5 +91,4 @@ refresh_users = 6.0
timeout_chat = 30.0
[emote]
sheet = "emotes.png"
schema = "emotes.json"