このコミットが含まれているのは:
@@ -3,3 +3,6 @@ __pycache__
|
|||||||
/nizika_talking.wav
|
/nizika_talking.wav
|
||||||
/youtube.py
|
/youtube.py
|
||||||
/log.txt
|
/log.txt
|
||||||
|
/outputs/**
|
||||||
|
!/outputs/**/
|
||||||
|
!/outputs/**/.gitkeep
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import wave
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Callable, TypedDict, cast
|
from typing import Any, Callable, TypedDict, cast
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import emoji
|
import emoji
|
||||||
@@ -30,8 +30,10 @@ from pygame.time import Clock
|
|||||||
from pytchat.core.pytchat import PytchatCore
|
from pytchat.core.pytchat import PytchatCore
|
||||||
from pytchat.processors.default.processor import Chat
|
from pytchat.processors.default.processor import Chat
|
||||||
|
|
||||||
|
import video
|
||||||
from aques import Aques
|
from aques import Aques
|
||||||
from common_module import CommonModule
|
from common_module import CommonModule
|
||||||
|
from niconico import NicoNico
|
||||||
from nizika_ai.config import DB
|
from nizika_ai.config import DB
|
||||||
from nizika_ai.consts import Character, GPTModel, Platform, QueryType
|
from nizika_ai.consts import Character, GPTModel, Platform, QueryType
|
||||||
from nizika_ai.models import Answer, AnsweredFlag, Query, User
|
from nizika_ai.models import Answer, AnsweredFlag, Query, User
|
||||||
@@ -59,6 +61,8 @@ def main (
|
|||||||
y = CWindow.HEIGHT - 56.25,
|
y = CWindow.HEIGHT - 56.25,
|
||||||
balloon = balloon)
|
balloon = balloon)
|
||||||
snack_time = SnackTime (game)
|
snack_time = SnackTime (game)
|
||||||
|
nico_video = NicoVideo (game)
|
||||||
|
nico_video_layer = nico_video.layer
|
||||||
CurrentTime (game, DEERJIKA_FONT)
|
CurrentTime (game, DEERJIKA_FONT)
|
||||||
|
|
||||||
broadcast: Broadcast | None = None
|
broadcast: Broadcast | None = None
|
||||||
@@ -79,13 +83,14 @@ def main (
|
|||||||
pygame.quit ()
|
pygame.quit ()
|
||||||
sys.exit ()
|
sys.exit ()
|
||||||
|
|
||||||
if (not balloon.enabled) and (not snack_time.enabled):
|
if ((not balloon.enabled)
|
||||||
|
and (not snack_time.enabled)
|
||||||
|
and (not nico_video.enabled)):
|
||||||
if waiting_balloon[0]:
|
if waiting_balloon[0]:
|
||||||
deerjika.talk (waiting_balloon[1], waiting_balloon[2])
|
deerjika.talk (waiting_balloon[1], waiting_balloon[2])
|
||||||
if waiting_balloon[2] == WATCHING_MSG:
|
if waiting_balloon[2] == WATCHING_MSG:
|
||||||
...
|
nico_video.play ()
|
||||||
else:
|
waiting_balloon = (False, '', '')
|
||||||
waiting_balloon = (False, '', '')
|
|
||||||
|
|
||||||
if now_m - last_flags_poll >= 1:
|
if now_m - last_flags_poll >= 1:
|
||||||
last_flags_poll = now_m
|
last_flags_poll = now_m
|
||||||
@@ -117,6 +122,8 @@ def main (
|
|||||||
query = Query.find (answer.query_id)
|
query = Query.find (answer.query_id)
|
||||||
deerjika.talk (None, answer.content)
|
deerjika.talk (None, answer.content)
|
||||||
waiting_balloon = (True, None, WATCHING_MSG)
|
waiting_balloon = (True, None, WATCHING_MSG)
|
||||||
|
nico_video = NicoVideo (game, query.transfer_data.get ('video_code'))
|
||||||
|
nico_video.layer = nico_video_layer
|
||||||
answer_flag.answered = True
|
answer_flag.answered = True
|
||||||
answer_flag.save ()
|
answer_flag.save ()
|
||||||
case _:
|
case _:
|
||||||
@@ -975,10 +982,19 @@ class NicoVideo (Video):
|
|||||||
def __init__ (
|
def __init__ (
|
||||||
self,
|
self,
|
||||||
game: Game,
|
game: Game,
|
||||||
video_code: str,
|
video_code: str | None = None,
|
||||||
):
|
):
|
||||||
comments = fetch_comments (video_code)
|
if video_code is None:
|
||||||
#super ().__init__ (game, )
|
super ().__init__ (game, './assets/snack_time.mp4')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
comments = fetch_comments (video_code)
|
||||||
|
fetch_nico_video (video_code, comments)
|
||||||
|
super ().__init__ (game, './outputs/output.mp4')
|
||||||
|
except Exception:
|
||||||
|
super ().__init__ (game, './assets/snack_time.mp4')
|
||||||
|
(self.width, self.height) = (CWindow.HEIGHT * 16 // 9, CWindow.HEIGHT)
|
||||||
|
(self.x, self.y) = ((CWindow.WIDTH - self.width) / 2, 0)
|
||||||
|
|
||||||
|
|
||||||
class SnackTime (Video):
|
class SnackTime (Video):
|
||||||
@@ -1000,6 +1016,37 @@ def fetch_bytes_from_url (
|
|||||||
return res.content
|
return res.content
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_nico_video (
|
||||||
|
video_code: str,
|
||||||
|
comments: list[CommentDict],
|
||||||
|
) -> None:
|
||||||
|
client = NicoNico ()
|
||||||
|
|
||||||
|
watch_data = client.video.watch.get_watch_data (video_code)
|
||||||
|
|
||||||
|
outputs = client.video.watch.get_outputs (watch_data)
|
||||||
|
output_label = next (iter (outputs))
|
||||||
|
|
||||||
|
downloaded_path = client.video.watch.download_video (
|
||||||
|
watch_data, output_label, '%(title)s.%(ext)s')
|
||||||
|
os.rename (downloaded_path, './outputs/base.mp4')
|
||||||
|
|
||||||
|
ass = video.build_ass (cast (Any, comments), 640, 480)
|
||||||
|
with open ('./outputs/comments.ass', 'w', encoding = 'utf-8') as f:
|
||||||
|
f.write (ass)
|
||||||
|
|
||||||
|
subprocess.run (
|
||||||
|
['ffmpeg',
|
||||||
|
'-i', './outputs/base.mp4',
|
||||||
|
'-vf', 'ass=./outputs/comments.ass',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-crf', '18',
|
||||||
|
'-preset', 'veryfast',
|
||||||
|
'-c:a', 'copy',
|
||||||
|
'./outputs/output.mp4'],
|
||||||
|
check = True)
|
||||||
|
|
||||||
|
|
||||||
def fetch_comments (
|
def fetch_comments (
|
||||||
video_code: str,
|
video_code: str,
|
||||||
) -> list[CommentDict]:
|
) -> list[CommentDict]:
|
||||||
@@ -1011,6 +1058,7 @@ def fetch_comments (
|
|||||||
text = True)
|
text = True)
|
||||||
return cast(list[CommentDict], json.loads (result.stdout))
|
return cast(list[CommentDict], json.loads (result.stdout))
|
||||||
|
|
||||||
|
|
||||||
def add_query (
|
def add_query (
|
||||||
broadcast: Broadcast,
|
broadcast: Broadcast,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_W, DEFAULT_H = 1280, 720
|
||||||
|
FONT_NAME = 'Noto Sans CJK JP'
|
||||||
|
BASE_FONT = 42
|
||||||
|
SCROLL_DURATION = 4.
|
||||||
|
STATIC_DURATION = 3.
|
||||||
|
MARGIN_X = 20
|
||||||
|
LANE_PADDING = 6
|
||||||
|
|
||||||
|
|
||||||
|
def ass_time (
|
||||||
|
t: float,
|
||||||
|
) -> str:
|
||||||
|
if t < 0:
|
||||||
|
t = 0
|
||||||
|
cs = int (round (t * 100))
|
||||||
|
s, cs = divmod (cs, 100)
|
||||||
|
m, s = divmod (s, 60)
|
||||||
|
h, m = divmod (m, 60)
|
||||||
|
return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
|
||||||
|
|
||||||
|
def escape_ass (
|
||||||
|
text: str,
|
||||||
|
) -> str:
|
||||||
|
text = text.replace ('\n', ' ').replace ('\r', ' ')
|
||||||
|
text = text.replace ('{', r'\{').replace ('}', r'\}')
|
||||||
|
text = text.replace ('\\', r'\\')
|
||||||
|
return text.strip ()
|
||||||
|
|
||||||
|
def approx_text_width_px (
|
||||||
|
text: str,
|
||||||
|
font_size: float,
|
||||||
|
) -> float:
|
||||||
|
return max (40., .62 * font_size * len (text))
|
||||||
|
|
||||||
|
|
||||||
|
def build_ass (
|
||||||
|
comments: list[dict[str, Any]],
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
) -> str:
|
||||||
|
header = f"""[Script Info]
|
||||||
|
ScriptType: v4.00+
|
||||||
|
PlayResX: {w}
|
||||||
|
PlayResY: {h}
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Danmaku,{FONT_NAME},{BASE_FONT},&H00FFFFFF,&H00000000,&H00000000,&H64000000,0,0,0,0,100,100,0,0,1,2,1,7,{MARGIN_X},{MARGIN_X},10,1
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
"""
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
lane_h = int (BASE_FONT + LANE_PADDING)
|
||||||
|
lane_count = max (6, (h - 80) // lane_h)
|
||||||
|
lane_next_free = [0.] * lane_count
|
||||||
|
|
||||||
|
top_rows = max (1, (h // 5) // lane_h)
|
||||||
|
bottom_rows = top_rows
|
||||||
|
top_next_free = [0.] * top_rows
|
||||||
|
bottom_next_free = [0.] * bottom_rows
|
||||||
|
|
||||||
|
def pick_lane (
|
||||||
|
next_free: list[float],
|
||||||
|
start: float,
|
||||||
|
) -> int:
|
||||||
|
for i, nf in enumerate (next_free):
|
||||||
|
if nf <= start:
|
||||||
|
return i
|
||||||
|
return min (range (len (next_free)), key = lambda i: next_free[i])
|
||||||
|
|
||||||
|
for c in comments:
|
||||||
|
text = escape_ass (c['content'])
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = c['vpos_ms']
|
||||||
|
end = c['vpos_ms'] + SCROLL_DURATION
|
||||||
|
lane = pick_lane (lane_next_free, start)
|
||||||
|
y = 40 + lane * lane_h
|
||||||
|
|
||||||
|
tw = approx_text_width_px (text, 1.)
|
||||||
|
x1 = w + MARGIN_X
|
||||||
|
x2 = -tw - MARGIN_X
|
||||||
|
|
||||||
|
lane_next_free[lane] = start + (SCROLL_DURATION * .65)
|
||||||
|
|
||||||
|
override = rf"{{\fs1\c&H00FFFFFF\move({int(x1)},{int(y)},{int(x2)},{int(y)})}}"
|
||||||
|
events.append (
|
||||||
|
f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Danmaku,,0,0,0,,{override}{text}")
|
||||||
|
|
||||||
|
return header + '\n'.join (events) + '\n'
|
||||||
新しい課題から参照
ユーザをブロックする