コミットを比較
12 コミット
4b986cb84e
...
9860559c00
作成者 | SHA1 | 日付 |
---|---|---|
n9k | 9860559c00 | |
n9k | d8d5796e3d | |
n9k | 03acd14b77 | |
n9k | 9e7f3f875c | |
n9k | f9b8f7c6f8 | |
n9k | 7db8895750 | |
n9k | a594b6ed73 | |
n9k | f081284876 | |
n9k | 51265fb277 | |
n9k | 31ce80b2bf | |
n9k | 84ad17f13d | |
n9k | 8f06121d8f |
|
@ -82,6 +82,7 @@ def toml_to_flask_section_memory(config):
|
|||
def toml_to_flask_section_tasks(config):
|
||||
cfg = config['tasks']
|
||||
return {
|
||||
'TASK_ROTATE_EYES': cfg['rotate_eyes'],
|
||||
'TASK_ROTATE_USERS': cfg['rotate_users'],
|
||||
'TASK_ROTATE_CAPTCHAS': cfg['rotate_captchas'],
|
||||
'TASK_ROTATE_WEBSOCKETS': cfg['rotate_websockets'],
|
||||
|
@ -112,11 +113,16 @@ def toml_to_flask_section_chat(config):
|
|||
|
||||
def toml_to_flask_section_flood(config):
|
||||
cfg = config['flood']
|
||||
assert cfg['video']['max_eyes'] >= 0
|
||||
return {
|
||||
'FLOOD_MESSAGE_DURATION': cfg['messages']['duration'],
|
||||
'FLOOD_MESSAGE_THRESHOLD': cfg['messages']['threshold'],
|
||||
'FLOOD_LINE_DURATION': cfg['lines']['duration'],
|
||||
'FLOOD_LINE_THRESHOLD': cfg['lines']['threshold'],
|
||||
'FLOOD_VIDEO_MAX_EYES': cfg['video']['max_eyes'],
|
||||
'FLOOD_VIDEO_COOLDOWN': cfg['video']['cooldown'],
|
||||
'FLOOD_VIDEO_EYES_EXPIRE_AFTER': cfg['video']['expire_after'],
|
||||
'FLOOD_VIDEO_OVERWRITE': cfg['video']['overwrite'],
|
||||
}
|
||||
|
||||
def toml_to_flask_section_captcha(config):
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.chat import delete_chat_messages
|
||||
from anonstream.stream import get_stream_title, set_stream_title
|
||||
from anonstream.utils.user import USER_WEBSOCKET_ATTRS
|
||||
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
USERS = current_app.users
|
||||
USERS_UPDATE_BUFFER = current_app.users_update_buffer
|
||||
|
||||
class UnknownMethod(Exception):
|
||||
pass
|
||||
|
@ -28,6 +35,9 @@ class Failed(Exception):
|
|||
class Exit(Exception):
|
||||
pass
|
||||
|
||||
def json_dumps_contiguous(obj, **kwargs):
|
||||
return json.dumps(obj, **kwargs).replace(' ', r'\u0020')
|
||||
|
||||
def start_control_server_at(address):
|
||||
return asyncio.start_unix_server(serve_control_client, address)
|
||||
|
||||
|
@ -210,7 +220,7 @@ async def command_title_set(args):
|
|||
await set_stream_title(title)
|
||||
except OSError as e:
|
||||
raise Failed(str(e)) from e
|
||||
normal_options = ['set', json.dumps(title).replace(' ', r'\u0020')]
|
||||
normal_options = ['set', json_dumps_contiguous(title)]
|
||||
response = ''
|
||||
case []:
|
||||
raise Incomplete
|
||||
|
@ -218,6 +228,112 @@ async def command_title_set(args):
|
|||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_user_help(args):
|
||||
match args:
|
||||
case []:
|
||||
normal_options = ['help']
|
||||
response = (
|
||||
'Usage: user [show | attr USER | get USER ATTR | set USER ATTR VALUE]\n'
|
||||
'Commands:\n'
|
||||
' user [show]...........show all users\' tokens\n'
|
||||
' user attr USER........show names of a user\'s attributes\n'
|
||||
' user get USER ATTR....show an attribute of a user\n'
|
||||
' user set USER ATTR....set an attribute of a user\n'
|
||||
'Definitions:\n'
|
||||
' USER..................={token TOKEN | hash HASH}\n'
|
||||
' TOKEN.................a token\n'
|
||||
' HASH..................a token hash\n'
|
||||
' ATTR..................a user attribute, re:[a-z0-9_]+\n'
|
||||
)
|
||||
case [*garbage]:
|
||||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_user_show(args):
|
||||
match args:
|
||||
case []:
|
||||
normal_options = ['show']
|
||||
response = json.dumps(tuple(USERS_BY_TOKEN)) + '\n'
|
||||
case [*garbage]:
|
||||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_user_attr(args):
|
||||
match args:
|
||||
case []:
|
||||
raise Incomplete
|
||||
case ['token', token_json]:
|
||||
try:
|
||||
token = json.loads(token_json)
|
||||
except json.JSONDecodeError:
|
||||
raise BadArgument('could not decode token as json')
|
||||
try:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
except KeyError:
|
||||
raise Failed(f"no user exists with token {token!r}, try 'user show'")
|
||||
normal_options = ['attr', 'token', json_dumps_contiguous(token)]
|
||||
response = json.dumps(tuple(user.keys())) + '\n'
|
||||
case [*garbage]:
|
||||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_user_get(args):
|
||||
match args:
|
||||
case ['token', token_json, attr]:
|
||||
try:
|
||||
token = json.loads(token_json)
|
||||
except json.JSONDecodeError:
|
||||
raise BadArgument('could not decode token as json')
|
||||
try:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
except KeyError:
|
||||
raise Failed(f"no user exists with token {token!r}, try 'user show'")
|
||||
try:
|
||||
value = user[attr]
|
||||
except KeyError:
|
||||
raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}'")
|
||||
try:
|
||||
value_json = json.dumps(value)
|
||||
except TypeError:
|
||||
raise Failed(f'attribute {attr!r} is not JSON serializable')
|
||||
normal_options = ['get', 'token', json_dumps_contiguous(token), attr]
|
||||
response = value_json + '\n'
|
||||
case []:
|
||||
raise Incomplete
|
||||
case [*garbage]:
|
||||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_user_set(args):
|
||||
match args:
|
||||
case ['token', token_json, attr, value_json]:
|
||||
try:
|
||||
token = json.loads(token_json)
|
||||
except json.JSONDecodeError:
|
||||
raise BadArgument('could not decode token as json')
|
||||
try:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
except KeyError:
|
||||
raise Failed(f"no user exists with token {token!r}, try 'user show'")
|
||||
try:
|
||||
value = user[attr]
|
||||
except KeyError:
|
||||
raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}")
|
||||
try:
|
||||
value = json.loads(value_json)
|
||||
except JSON.JSONDecodeError:
|
||||
raise Failed('could not decode json')
|
||||
user[attr] = value
|
||||
if attr in USER_WEBSOCKET_ATTRS:
|
||||
USERS_UPDATE_BUFFER.add(token)
|
||||
normal_options = ['set', 'token', json_dumps_contiguous(token), attr, json_dumps_contiguous(value)]
|
||||
response = ''
|
||||
case []:
|
||||
raise Incomplete
|
||||
case [*garbage]:
|
||||
raise Garbage(garbage)
|
||||
return normal_options, response
|
||||
|
||||
async def command_chat_help(args):
|
||||
match args:
|
||||
case []:
|
||||
|
@ -254,6 +370,7 @@ METHOD_HELP = 'help'
|
|||
METHOD_EXIT = 'exit'
|
||||
METHOD_TITLE = 'title'
|
||||
METHOD_CHAT = 'chat'
|
||||
METHOD_USER = 'user'
|
||||
|
||||
METHOD_COMMAND_FUNCTIONS = {
|
||||
METHOD_HELP: {
|
||||
|
@ -274,5 +391,13 @@ METHOD_COMMAND_FUNCTIONS = {
|
|||
None: command_chat_help,
|
||||
'help': command_chat_help,
|
||||
'delete': command_chat_delete,
|
||||
}
|
||||
},
|
||||
METHOD_USER: {
|
||||
None: command_user_show,
|
||||
'help': command_user_help,
|
||||
'show': command_user_show,
|
||||
'attr': command_user_attr,
|
||||
'get': command_user_get,
|
||||
'set': command_user_set,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -38,7 +38,8 @@ def generate_tripcode(password):
|
|||
background_colour = generate_colour(
|
||||
seed='tripcode-background\0' + digest,
|
||||
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
|
||||
contrast=5.0,
|
||||
min_contrast=5.0,
|
||||
max_contrast=5.0,
|
||||
)
|
||||
foreground_colour = generate_maximum_contrast_colour(
|
||||
seed='tripcode-foreground\0' + digest,
|
||||
|
|
|
@ -26,7 +26,7 @@ def generate_user(timestamp, token, broadcaster, presence):
|
|||
colour = generate_colour(
|
||||
seed='name\0' + token,
|
||||
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
|
||||
contrast=4.53,
|
||||
min_contrast=4.53,
|
||||
)
|
||||
token_hash, tag = generate_token_hash_and_tag(token)
|
||||
return {
|
||||
|
@ -43,9 +43,14 @@ def generate_user(timestamp, token, broadcaster, presence):
|
|||
'last': {
|
||||
'seen': timestamp,
|
||||
'watching': -inf,
|
||||
'eyes': -inf,
|
||||
},
|
||||
'presence': presence,
|
||||
'linespan': deque(),
|
||||
'eyes': {
|
||||
'total': 0,
|
||||
'current': {},
|
||||
},
|
||||
}
|
||||
|
||||
def get_default_name(user):
|
||||
|
|
|
@ -6,7 +6,7 @@ from quart.asgi import ASGIHTTPConnection as ASGIHTTPConnection_
|
|||
from quart.utils import encode_headers
|
||||
|
||||
|
||||
RESPONSE_ITERATOR_TIMEOUT = 10
|
||||
RESPONSE_ITERATOR_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class ASGIHTTPConnection(ASGIHTTPConnection_):
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import math
|
||||
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
|
||||
from anonstream.captcha import get_captcha_image
|
||||
from anonstream.segments import segments
|
||||
from anonstream.segments import segments, StopSendingSegments
|
||||
from anonstream.stream import is_online, get_stream_uptime
|
||||
from anonstream.user import watched
|
||||
from anonstream.user import watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required
|
||||
from anonstream.utils.security import generate_csp
|
||||
|
||||
|
@ -25,8 +28,20 @@ async def stream(user):
|
|||
if not is_online():
|
||||
return abort(404)
|
||||
|
||||
try:
|
||||
eyes_id = create_eyes(user, dict(request.headers))
|
||||
except RatelimitedEyes as e:
|
||||
retry_after, *_ = e.args
|
||||
return TooManyRequests(), {'Retry-After': math.ceil(retry_after)}
|
||||
except EyesException:
|
||||
return abort(429)
|
||||
|
||||
def segment_read_hook(uri):
|
||||
print(f'{uri}: {user["token"]}')
|
||||
try:
|
||||
renew_eyes(user, eyes_id, just_read_new_segment=True)
|
||||
except EyesException as e:
|
||||
raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e
|
||||
print(f'{uri}: {eyes_id}~{user["token"]}')
|
||||
watched(user)
|
||||
|
||||
generator = segments(segment_read_hook, token=user['token'])
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
|
@ -19,6 +22,15 @@ USERS_BY_TOKEN = current_app.users_by_token
|
|||
USERS = current_app.users
|
||||
USERS_UPDATE_BUFFER = current_app.users_update_buffer
|
||||
|
||||
TOKEN_ALPHABET = (
|
||||
string.digits
|
||||
+ string.ascii_lowercase
|
||||
+ string.ascii_uppercase
|
||||
+ string.punctuation
|
||||
+ ' '
|
||||
)
|
||||
RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET))
|
||||
|
||||
def check_auth(context):
|
||||
auth = context.authorization
|
||||
return (
|
||||
|
@ -68,6 +80,12 @@ def with_user_from(context):
|
|||
or context.cookies.get('token')
|
||||
or generate_token()
|
||||
)
|
||||
if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']):
|
||||
raise abort(401)
|
||||
|
||||
# Reject invalid tokens
|
||||
if not RE_TOKEN.fullmatch(token):
|
||||
raise abort(400)
|
||||
|
||||
# Update / create user
|
||||
user = USERS_BY_TOKEN.get(token)
|
||||
|
|
|
@ -22,6 +22,9 @@ class Stale(Exception):
|
|||
class UnsafePath(Exception):
|
||||
pass
|
||||
|
||||
class StopSendingSegments(Exception):
|
||||
pass
|
||||
|
||||
def get_mtime():
|
||||
try:
|
||||
mtime = os.path.getmtime(CONFIG['SEGMENT_PLAYLIST'])
|
||||
|
@ -148,7 +151,15 @@ async def segments(segment_read_hook=lambda uri: None, token=None):
|
|||
)
|
||||
break
|
||||
|
||||
segment_read_hook(uri)
|
||||
try:
|
||||
segment_read_hook(uri)
|
||||
except StopSendingSegments as e:
|
||||
reason, *_ = e.args
|
||||
print(
|
||||
f'[debug @ {time.time():.3f}: {token=}] '
|
||||
f'told to stop sending segments: {reason}'
|
||||
)
|
||||
break
|
||||
try:
|
||||
async with aiofiles.open(path, 'rb') as fp:
|
||||
while chunk := await fp.read(8192):
|
||||
|
|
|
@ -43,6 +43,21 @@ def with_period(period):
|
|||
|
||||
return periodically
|
||||
|
||||
@with_period(CONFIG['TASK_ROTATE_EYES'])
|
||||
@with_timestamp
|
||||
async def t_delete_eyes(timestamp, iteration):
|
||||
if iteration == 0:
|
||||
return
|
||||
else:
|
||||
for user in USERS:
|
||||
to_delete = []
|
||||
for eyes_id, eyes in user['eyes']['current'].items():
|
||||
renewed_ago = timestamp - eyes['renewed']
|
||||
if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']:
|
||||
to_delete.append(eyes_id)
|
||||
for eyes_id in to_delete:
|
||||
user['eyes']['current'].pop(eyes_id)
|
||||
|
||||
@with_period(CONFIG['TASK_ROTATE_USERS'])
|
||||
@with_timestamp
|
||||
async def t_sunset_users(timestamp, iteration):
|
||||
|
@ -166,6 +181,7 @@ async def t_broadcast_stream_info_update(iteration):
|
|||
if payload:
|
||||
broadcast(USERS, payload={'type': 'info', **payload})
|
||||
|
||||
current_app.add_background_task(t_delete_eyes)
|
||||
current_app.add_background_task(t_sunset_users)
|
||||
current_app.add_background_task(t_expire_captchas)
|
||||
current_app.add_background_task(t_close_websockets)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import operator
|
||||
import time
|
||||
from math import inf
|
||||
|
||||
|
@ -25,6 +26,21 @@ class BadAppearance(ValueError):
|
|||
class BadCaptcha(ValueError):
|
||||
pass
|
||||
|
||||
class EyesException(Exception):
|
||||
pass
|
||||
|
||||
class TooManyEyes(EyesException):
|
||||
pass
|
||||
|
||||
class RatelimitedEyes(EyesException):
|
||||
pass
|
||||
|
||||
class DeletedEyes(EyesException):
|
||||
pass
|
||||
|
||||
class ExpiredEyes(EyesException):
|
||||
pass
|
||||
|
||||
def add_state(user, **state):
|
||||
state_id = time.time_ns() // 1_000_000
|
||||
user['state'][state_id] = state
|
||||
|
@ -219,3 +235,55 @@ def get_users_by_presence(timestamp):
|
|||
for user in get_users_and_update_presence(timestamp):
|
||||
users_by_presence[user['presence']].append(user)
|
||||
return users_by_presence
|
||||
|
||||
@with_timestamp
|
||||
def create_eyes(timestamp, user, headers):
|
||||
# Enforce cooldown
|
||||
last_created_ago = timestamp - user['last']['eyes']
|
||||
cooldown_ended_ago = last_created_ago - CONFIG['FLOOD_VIDEO_COOLDOWN']
|
||||
cooldown_remaining = -cooldown_ended_ago
|
||||
if cooldown_remaining > 0:
|
||||
raise RatelimitedEyes(cooldown_remaining)
|
||||
|
||||
# Enforce max_eyes & overwrite
|
||||
if len(user['eyes']['current']) >= CONFIG['FLOOD_VIDEO_MAX_EYES']:
|
||||
# Treat eyes as a stack, do not create new eyes if it would
|
||||
# cause the limit to be exceeded
|
||||
if not CONFIG['FLOOD_VIDEO_OVERWRITE']:
|
||||
raise TooManyEyes
|
||||
# Treat eyes as a queue, expire old eyes upon creating new eyes
|
||||
# if the limit would have been exceeded otherwise
|
||||
elif user['eyes']['current']:
|
||||
oldest_eyes_id = min(user['eyes']['current'])
|
||||
user['eyes']['current'].pop(oldest_eyes_id)
|
||||
|
||||
# Create eyes
|
||||
eyes_id = user['eyes']['total']
|
||||
user['eyes']['total'] += 1
|
||||
user['last']['eyes'] = timestamp
|
||||
user['eyes']['current'][eyes_id] = {
|
||||
'id': eyes_id,
|
||||
'token': user['token'],
|
||||
'n_segments': 0,
|
||||
'headers': headers,
|
||||
'created': timestamp,
|
||||
'renewed': timestamp,
|
||||
}
|
||||
return eyes_id
|
||||
|
||||
@with_timestamp
|
||||
def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False):
|
||||
try:
|
||||
eyes = user['eyes']['current'][eyes_id]
|
||||
except KeyError:
|
||||
raise DeletedEyes
|
||||
|
||||
# Enforce expire_after (if the background task hasn't already)
|
||||
renewed_ago = timestamp - eyes['renewed']
|
||||
if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']:
|
||||
user['eyes']['current'].pop(eyes_id)
|
||||
raise ExpiredEyes
|
||||
|
||||
if just_read_new_segment:
|
||||
eyes['n_segments'] += 1
|
||||
eyes['renewed'] = timestamp
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import re
|
||||
import random
|
||||
from math import inf
|
||||
|
||||
class NotAColor(Exception):
|
||||
pass
|
||||
|
@ -47,7 +48,7 @@ def _tc_to_sc(tc):
|
|||
Almost-inverse of _sc_to_tc.
|
||||
|
||||
The function _sc_to_tc is not injective (because of the discontinuity at
|
||||
sc=0.03928), thus it has no true inverse. In this implementation, whenever
|
||||
sc=0.03928), thus it has no true inverse. In this implementation, whenever
|
||||
for a given `tc` there are two distinct values of `sc` such that
|
||||
sc_to_tc(`sc`)=`tc`, the smaller sc is chosen. (The smaller one is less
|
||||
expensive to compute).
|
||||
|
@ -89,22 +90,23 @@ def get_contrast(bg, fg):
|
|||
)
|
||||
return (max(lumas) + 0.05) / (min(lumas) + 0.05)
|
||||
|
||||
def generate_colour(seed, bg, contrast=4.5, lighter=True):
|
||||
def generate_colour(seed, bg, min_contrast=4.5, max_contrast=inf, lighter=True):
|
||||
'''
|
||||
Generate a random colour with given contrast to `bg`.
|
||||
Generate a random colour with a contrast to `bg` in a given interval.
|
||||
|
||||
Channels of `t` are uniformly distributed. No characteristics of the
|
||||
returned colour are guaranteed to be chosen uniformly from the space of
|
||||
possible values.
|
||||
This works by generating an intermediate 3-tuple `t` and transforming it
|
||||
into the returned colour. Channels of `t` are uniformly distributed, but no
|
||||
characteristics of the returned colour are guaranteed to be chosen uniformly
|
||||
from the space of possible values.
|
||||
|
||||
If `lighter` is true, the returned colour is forced to have a higher
|
||||
relative luminance than `bg`. This is fine if `bg` is dark; if `bg` is
|
||||
not dark, the space of possible returned colours will be a lot smaller
|
||||
(and might be empty). If `lighter` is false, the returned colour is
|
||||
forced to have a lower relative luminance than `bg`.
|
||||
relative luminance than `bg`. This is fine if `bg` is dark; if `bg` is not
|
||||
dark, the space of possible returned colours will be a lot smaller (and
|
||||
might be empty). If `lighter` is false, the returned colour is forced to
|
||||
have a lower relative luminance than `bg`.
|
||||
|
||||
It's simple to calculate the maximum possible contrast between `bg` and
|
||||
any other colour. (The minimum contrast is always 1.)
|
||||
It's simple to calculate the maximum possible contrast between `bg` and any
|
||||
other colour. (The minimum contrast is always 1.)
|
||||
|
||||
>>> bg = (0x23, 0x23, 0x27)
|
||||
>>> luma = get_relative_luminance(bg)
|
||||
|
@ -113,11 +115,13 @@ def generate_colour(seed, bg, contrast=4.5, lighter=True):
|
|||
>>> 1.05 / (luma + 0.05) # maximum contrast for colours with greater luma
|
||||
15.657919499763137
|
||||
|
||||
There are values of `contrast` for which the space of possible returned
|
||||
colours is empty. For example a `contrast` greater than 21 is always
|
||||
impossible, but the exact upper bound depends on `bg`. The desired
|
||||
relative luminance of the returned colour must exist in the interval [0,1].
|
||||
The formula for desired luma is given below.
|
||||
There are contrast intervals for which the space of possible returned
|
||||
colours is empty. For example a contrast greater than 21 is always
|
||||
impossible, but the exact upper bound depends on `bg`. The desired relative
|
||||
luminance of the returned colour must exist in the interval [0,1]. The
|
||||
formula for desired luma is given below. This is for one particular
|
||||
contrast but the same formula can be used twice (once with `min_contrast` and
|
||||
once with `max_contrast`) to get a range of desired lumas.
|
||||
|
||||
>>> bg_luma = get_relative_luminance(bg)
|
||||
>>> desired_luma = (
|
||||
|
@ -131,32 +135,37 @@ def generate_colour(seed, bg, contrast=4.5, lighter=True):
|
|||
r = random.Random(seed)
|
||||
|
||||
if lighter:
|
||||
desired_luma = contrast * (get_relative_luminance(bg) + 0.05) - 0.05
|
||||
min_desired_luma = min_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
|
||||
max_desired_luma = max_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
|
||||
else:
|
||||
desired_luma = (get_relative_luminance(bg) + 0.05) / contrast - 0.05
|
||||
min_desired_luma = (get_relative_luminance(bg) + 0.05) / max_contrast - 0.05
|
||||
max_desired_luma = (get_relative_luminance(bg) + 0.05) / min_contrast - 0.05
|
||||
|
||||
V = (0.2126, 0.7152, 0.0722)
|
||||
indices = [0, 1, 2]
|
||||
r.shuffle(indices)
|
||||
i, j, k = indices
|
||||
|
||||
# V[i] * ci + V[j] * 0 + V[k] * 0 <= desired_luma
|
||||
# V[i] * ci + V[j] * 1 + V[k] * 1 >= desired_luma
|
||||
ci_upper = (desired_luma - V[j] * 0 - V[k] * 0) / V[i]
|
||||
ci_lower = (desired_luma - V[j] * 1 - V[k] * 1) / V[i]
|
||||
ci = r.uniform(max(0, ci_lower), min(1, ci_upper))
|
||||
# V[i] * ti + V[j] * 0 + V[k] * 0 <= max_desired_luma
|
||||
# V[i] * ti + V[j] * 1 + V[k] * 1 >= min_desired_luma
|
||||
ti_upper = (max_desired_luma - V[j] * 0 - V[k] * 0) / V[i]
|
||||
ti_lower = (min_desired_luma - V[j] * 1 - V[k] * 1) / V[i]
|
||||
ti = r.uniform(max(0, ti_lower), min(1, ti_upper))
|
||||
|
||||
# V[i] * ci + V[j] * cj + V[k] * 0 <= desired_luma
|
||||
# V[i] * ci + V[j] * cj + V[k] * 1 >= desired_luma
|
||||
cj_upper = (desired_luma - V[i] * ci - V[k] * 0) / V[j]
|
||||
cj_lower = (desired_luma - V[i] * ci - V[k] * 1) / V[j]
|
||||
cj = r.uniform(max(0, cj_lower), min(1, cj_upper))
|
||||
# V[i] * ti + V[j] * tj + V[k] * 0 <= max_desired_luma
|
||||
# V[i] * ti + V[j] * tj + V[k] * 1 >= min_desired_luma
|
||||
tj_upper = (max_desired_luma - V[i] * ti - V[k] * 0) / V[j]
|
||||
tj_lower = (min_desired_luma - V[i] * ti - V[k] * 1) / V[j]
|
||||
tj = r.uniform(max(0, tj_lower), min(1, tj_upper))
|
||||
|
||||
# V[i] * ci + V[j] * cj + V[k] * ck = desired_luma
|
||||
ck = (desired_luma - V[i] * ci - V[j] * cj) / V[k]
|
||||
# V[i] * ti + V[j] * tj + V[k] * tk <= max_desired_luma
|
||||
# V[i] * ti + V[j] * tj + V[k] * tk >= min_desired_luma
|
||||
tk_upper = (max_desired_luma - V[i] * ti - V[j] * tj) / V[k]
|
||||
tk_lower = (min_desired_luma - V[i] * ti - V[j] * tj) / V[k]
|
||||
tk = r.uniform(max(0, tk_lower), min(1, tk_upper))
|
||||
|
||||
t = [None, None, None]
|
||||
t[i], t[j], t[k] = ci, cj, ck
|
||||
t[i], t[j], t[k] = ti, tj, tk
|
||||
|
||||
s = map(_tc_to_sc, t)
|
||||
colour = map(lambda sc: round(sc * 255), s)
|
||||
|
@ -185,10 +194,12 @@ def generate_maximum_contrast_colour(seed, bg, proportion_of_max=31/32):
|
|||
max_darker_contrast = get_maximum_contrast(bg, lighter=False)
|
||||
|
||||
max_contrast = max(max_lighter_contrast, max_darker_contrast)
|
||||
practical_max_contrast = max_contrast * proportion_of_max
|
||||
colour = generate_colour(
|
||||
seed,
|
||||
bg,
|
||||
contrast=max_contrast * proportion_of_max,
|
||||
min_contrast=practical_max_contrast,
|
||||
max_contrast=practical_max_contrast,
|
||||
lighter=max_lighter_contrast > max_darker_contrast,
|
||||
)
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ from math import inf
|
|||
|
||||
from quart import escape, Markup
|
||||
|
||||
USER_WEBSOCKET_ATTRS = {'broadcaster', 'name', 'color', 'tripcode', 'tag'}
|
||||
|
||||
Presence = Enum(
|
||||
'Presence',
|
||||
names=(
|
||||
|
@ -44,8 +46,7 @@ def trilean(presence):
|
|||
return None
|
||||
|
||||
def get_user_for_websocket(user):
|
||||
keys = ('broadcaster', 'name', 'color', 'tripcode', 'tag')
|
||||
return {
|
||||
**{key: user[key] for key in keys},
|
||||
**{key: user[key] for key in USER_WEBSOCKET_ATTRS},
|
||||
'watching': trilean(user['presence']),
|
||||
}
|
||||
|
|
|
@ -17,7 +17,8 @@ from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
|
|||
CONFIG = current_app.config
|
||||
|
||||
async def websocket_outbound(queue, user):
|
||||
payload = {
|
||||
await websocket.send_json({'type': 'ping'})
|
||||
await websocket.send_json({
|
||||
'type': 'init',
|
||||
'nonce': generate_nonce(),
|
||||
'title': await get_stream_title(),
|
||||
|
@ -31,9 +32,7 @@ async def websocket_outbound(queue, user):
|
|||
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
'pingpong': CONFIG['TASK_BROADCAST_PING'],
|
||||
}
|
||||
await websocket.send_json(payload)
|
||||
await websocket.send_json({'type': 'ping'})
|
||||
})
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload['type'] == 'close':
|
||||
|
|
|
@ -34,6 +34,7 @@ chat_messages = 8192
|
|||
chat_scrollback = 256
|
||||
|
||||
[tasks]
|
||||
rotate_eyes = 3.0
|
||||
rotate_users = 60.0
|
||||
rotate_captchas = 60.0
|
||||
rotate_websockets = 2.0
|
||||
|
@ -60,6 +61,12 @@ threshold = 4
|
|||
duration = 20.0
|
||||
threshold = 20
|
||||
|
||||
[flood.video]
|
||||
max_eyes = 3
|
||||
cooldown = 12.0
|
||||
expire_after = 5.0
|
||||
overwrite = true
|
||||
|
||||
[thresholds]
|
||||
user_notwatching = 8.0
|
||||
user_tentative = 20.0
|
||||
|
|
読み込み中…
新しいイシューから参照