伊地知ニジカ放送局だぬ゛ん゛. https://www.youtube.com/@deerjika
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1108 lines
32 KiB

  1. from __future__ import annotations
  2. import json
  3. import math
  4. import os
  5. import random
  6. import subprocess
  7. import sys
  8. import time
  9. import wave
  10. from datetime import datetime, timedelta
  11. from enum import Enum, auto
  12. from io import BytesIO
  13. from typing import Any, Callable, TypedDict, cast
  14. import cv2
  15. import emoji
  16. import ephem
  17. import numpy as np
  18. import pygame
  19. import pygame.gfxdraw
  20. import pytchat
  21. import requests
  22. from cv2 import VideoCapture
  23. from ephem import Moon, Observer, Sun
  24. from pygame import Rect, Surface
  25. from pygame.font import Font
  26. from pygame.mixer import Sound
  27. from pygame.time import Clock
  28. from pytchat.core.pytchat import PytchatCore
  29. from pytchat.processors.default.processor import Chat
  30. import video
  31. from aques import Aques
  32. from common_module import CommonModule
  33. from niconico import NicoNico
  34. from nizika_ai.config import DB
  35. from nizika_ai.consts import Character, GPTModel, Platform, QueryType
  36. from nizika_ai.models import Answer, AnsweredFlag, Query, User
  37. NIZIKA_NICO_DIR = os.environ.get ('NIZIKA_NICO_DIR') or '/root/nizika_nico'
  38. pygame.init ()
  39. FPS = 30
  40. SYSTEM_FONT = pygame.font.SysFont ('notosanscjkjp', 11, bold = True)
  41. USER_FONT = pygame.font.SysFont ('notosanscjkjp', 15, italic = True)
  42. DEERJIKA_FONT = pygame.font.SysFont ('07nikumarufont', 23)
  43. WATCHING_MSG = 'それじゃあ、さっそく見てみるぬ゛ん゛!'
  44. def main (
  45. ) -> None:
  46. game = Game ()
  47. Bg (game)
  48. balloon = Balloon (game)
  49. deerjika = Deerjika (game, DeerjikaPattern.RELAXED,
  50. x = CWindow.WIDTH * 3 / 4,
  51. y = CWindow.HEIGHT - 56.25,
  52. balloon = balloon)
  53. snack_time = SnackTime (game)
  54. nico_video = NicoVideo (game)
  55. nico_video_layer = nico_video.layer
  56. CurrentTime (game, DEERJIKA_FONT)
  57. broadcast: Broadcast | None = None
  58. try:
  59. broadcast = Broadcast (os.environ['BROADCAST_CODE'])
  60. except KeyError:
  61. broadcast = None
  62. waiting_balloon: tuple[bool, str | None, str] = (False, '', '')
  63. last_flags_poll: float = 0
  64. traced_af_ids: list[int] = []
  65. while True:
  66. now_m = time.monotonic ()
  67. for event in pygame.event.get ():
  68. if event.type == pygame.QUIT:
  69. pygame.quit ()
  70. sys.exit ()
  71. if ((not balloon.enabled)
  72. and (not snack_time.enabled)
  73. and (not nico_video.enabled)):
  74. if waiting_balloon[0]:
  75. deerjika.talk (waiting_balloon[1], waiting_balloon[2])
  76. if waiting_balloon[2] == WATCHING_MSG:
  77. nico_video.play ()
  78. waiting_balloon = (False, '', '')
  79. if now_m - last_flags_poll >= 1:
  80. last_flags_poll = now_m
  81. log (f"balloon: { balloon.enabled }, snack: { snack_time.enabled }")
  82. try:
  83. DB.begin_transaction ()
  84. answer_flags = (AnsweredFlag.where ('platform', Platform.YOUTUBE.value)
  85. .where ('answered', False)
  86. .where_not_in ('id', traced_af_ids)
  87. .get ())
  88. log (f"pending: { len (answer_flags) }")
  89. if answer_flags:
  90. answer_flag = random.choice (answer_flags)
  91. log (f"flag_id: { answer_flag.id }, answer_id: { answer_flag.answer_id }")
  92. answer = Answer.find (answer_flag.answer_id)
  93. match QueryType (answer.query_rel.query_type):
  94. case QueryType.YOUTUBE_COMMENT:
  95. query = Query.find (answer.query_id)
  96. deerjika.talk (query.content, answer.content)
  97. answer_flag.answered = True
  98. answer_flag.save ()
  99. case QueryType.SNACK_TIME:
  100. snack_time.play ()
  101. query = Query.find (answer.query_id)
  102. waiting_balloon = (True, query.content, answer.content)
  103. answer_flag.answered = True
  104. answer_flag.save ()
  105. case QueryType.KIRIBAN:
  106. query = Query.find (answer.query_id)
  107. deerjika.talk (None, answer.content)
  108. waiting_balloon = (True, None, WATCHING_MSG)
  109. nico_video = NicoVideo (game, query.transfer_data.get ('video_code'))
  110. nico_video.layer = nico_video_layer
  111. answer_flag.answered = True
  112. answer_flag.save ()
  113. case _:
  114. traced_af_ids.append (answer_flag.id)
  115. DB.commit ()
  116. except Exception as ex:
  117. DB.rollback ()
  118. log ('EXCEPTION in poll loop')
  119. print (ex)
  120. try:
  121. if broadcast is not None:
  122. add_query (broadcast)
  123. except Exception as ex:
  124. log ('EXCEPTION in adding a query')
  125. print (ex)
  126. game.redraw ()
  127. class Bg:
  128. """
  129. 背景オブゼクト管理用クラス
  130. Attributes:
  131. base (BgBase): 最背面
  132. grass (BgGrass): 草原部分
  133. jojoko (Jojoko): 大月ヨヨコ
  134. kita (KitaSun): き太く陽
  135. """
  136. base: BgBase
  137. grass: BgGrass
  138. jojoko: Jojoko
  139. kita: KitaSun
  140. def __init__ (
  141. self,
  142. game: Game,
  143. ):
  144. self.kita = KitaSun (game)
  145. self.base = BgBase (game, self.kita.sun, layer = self.kita.layer - 5)
  146. self.jojoko = Jojoko (game)
  147. self.grass = BgGrass (game)
  148. class DeerjikaPattern (Enum):
  149. """
  150. ニジカの状態
  151. Members:
  152. NORMAL: 通常
  153. RELAXED: 足パタパタ
  154. SLEEPING: 寝ニジカ
  155. DANCING: ダンシング・ニジカ
  156. """
  157. NORMAL = auto ()
  158. RELAXED = auto ()
  159. SLEEPING = auto ()
  160. DANCING = auto ()
  161. class Direction (Enum):
  162. """
  163. クリーチャの向き
  164. Members:
  165. LEFT: 左向き
  166. RIGHT: 右向き
  167. """
  168. LEFT = auto ()
  169. RIGHT = auto ()
  170. class Game:
  171. """
  172. ゲーム・クラス
  173. Attributes:
  174. clock (Clock): Clock オブゼクト
  175. frame (int): フレーム・カウンタ
  176. last_answered_at (datetime): 最後に回答した時刻
  177. now (datetime): 基準日時
  178. objects (list[GameObject]): 再描画するクラスのリスト
  179. screen (Surface): 基底スクリーン
  180. sky (Sky): 天体情報
  181. """
  182. bgm: Sound
  183. clock: Clock
  184. fps: float
  185. frame: int
  186. last_answered_at: datetime
  187. now: datetime
  188. objects: list[GameObject]
  189. screen: Surface
  190. sky: Sky
  191. def __init__ (
  192. self,
  193. ):
  194. self.now = datetime.now ()
  195. self.screen = pygame.display.set_mode ((CWindow.WIDTH, CWindow.HEIGHT))
  196. self.clock = Clock ()
  197. self.fps = FPS
  198. self.frame = 0
  199. self.objects = []
  200. self.bgm = Sound ('./assets/bgm.mp3')
  201. self.bgm.set_volume (.15)
  202. self.bgm.play (loops = -1)
  203. self._create_sky ()
  204. def redraw (
  205. self,
  206. ) -> None:
  207. self.now = datetime.now ()
  208. self.sky.observer.date = self.now - timedelta (hours = 9)
  209. for obj in sorted (self.objects, key = lambda obj: obj.layer):
  210. if obj.enabled:
  211. obj.redraw ()
  212. pygame.display.update ()
  213. delta_time = self.clock.tick (FPS) / 1000
  214. self.fps = 1 / delta_time
  215. if delta_time > 1 / FPS:
  216. for _ in range (int (FPS * delta_time) - 1):
  217. for obj in self.objects:
  218. if obj.enabled:
  219. obj.update ()
  220. def _create_sky (
  221. self,
  222. ) -> None:
  223. self.sky = Sky ()
  224. self.sky.observer = Observer ()
  225. self.sky.observer.lat = '35'
  226. self.sky.observer.lon = '139'
  227. class GameObject:
  228. """
  229. 各ゲーム・オブゼクトの基底クラス
  230. Attributes:
  231. arg (float): 回転角度 (rad)
  232. ax (float): X 軸に対する加速度 (px/frame^2)
  233. ay (float): y 軸に対する加速度 (px/frame^2)
  234. enabled (bool): オブゼクトの表示可否
  235. frame (int): フレーム・カウンタ
  236. game (Game): ゲーム基盤
  237. height (int): 高さ (px)
  238. vx (float): x 軸に対する速度 (px/frame)
  239. vy (float): y 軸に対する速度 (px/frame)
  240. width (int): 幅 (px)
  241. x (float): X 座標 (px)
  242. y (float): Y 座標 (px)
  243. """
  244. arg: float = 0
  245. ax: float = 0
  246. ay: float = 0
  247. enabled: bool = True
  248. frame: int
  249. game: Game
  250. height: int
  251. layer: float
  252. vx: float = 0
  253. vy: float = 0
  254. width: int
  255. x: float
  256. y: float
  257. def __init__ (
  258. self,
  259. game: Game,
  260. layer: float | None = None,
  261. enabled: bool = True,
  262. x: float = 0,
  263. y: float = 0,
  264. ):
  265. self.game = game
  266. self.enabled = enabled
  267. self.frame = 0
  268. if layer is None:
  269. if self.game.objects:
  270. layer = max (obj.layer for obj in self.game.objects) + 10
  271. else:
  272. layer = 0
  273. self.layer = layer
  274. self.x = x
  275. self.y = y
  276. self.game.objects.append (self)
  277. def redraw (
  278. self,
  279. ) -> None:
  280. self.update ()
  281. def update (
  282. self,
  283. ) -> None:
  284. self.x += self.vx
  285. self.y += self.vy
  286. self.vx += self.ax
  287. self.vy += self.ay
  288. self.frame += 1
  289. class BgBase (GameObject):
  290. """
  291. 背景
  292. Attributes:
  293. surface (Surface): 背景 Surface
  294. """
  295. bg: Surface
  296. bg_evening: Surface
  297. bg_grass: Surface
  298. bg_night: Surface
  299. sun: Sun
  300. def __init__ (
  301. self,
  302. game: Game,
  303. sun: Sun,
  304. layer: float,
  305. ):
  306. super ().__init__ (game, layer = layer)
  307. self.bg = self._load_image ('./assets/bg.jpg')
  308. self.bg_evening = self._load_image ('./assets/bg-evening.jpg')
  309. self.bg_grass = self._load_image ('./assets/bg-grass.png')
  310. self.bg_night = self._load_image ('./assets/bg-night.jpg')
  311. self.sun = sun
  312. @staticmethod
  313. def _load_image (
  314. path: str,
  315. ) -> Surface:
  316. return pygame.transform.scale (pygame.image.load (path),
  317. (CWindow.WIDTH, CWindow.HEIGHT))
  318. def redraw (
  319. self,
  320. ) -> None:
  321. date_tmp = self.game.sky.observer.date
  322. self.game.sky.observer.date = self.game.now.date ()
  323. sunrise_start: datetime = (
  324. (ephem.localtime (self.game.sky.observer.previous_rising (self.sun))
  325. - timedelta (minutes = 30)))
  326. sunrise_end: datetime = sunrise_start + timedelta (hours = 1)
  327. sunrise_centre: datetime = (
  328. sunrise_start + (sunrise_end - sunrise_start) / 2)
  329. sunset_start: datetime = (
  330. (ephem.localtime (self.game.sky.observer.next_setting (self.sun))
  331. - timedelta (minutes = 30)))
  332. sunset_end: datetime = sunset_start + timedelta (hours = 1)
  333. sunset_centre: datetime = (
  334. sunset_start + (sunset_end - sunset_start) / 2)
  335. self.game.sky.observer.date = date_tmp
  336. surface: Surface = ((self.bg
  337. if (sunrise_centre <= self.game.now < sunset_centre)
  338. else self.bg_night)
  339. .copy ())
  340. if sunrise_start <= self.game.now < sunrise_end:
  341. self.bg_evening.set_alpha (255 - int ((abs (self.game.now - sunrise_centre) * 510)
  342. / (sunrise_end - sunrise_centre)))
  343. elif sunset_start <= self.game.now < sunset_end:
  344. self.bg_evening.set_alpha (255 - int ((abs (self.game.now - sunset_centre) * 510)
  345. / (sunset_end - sunset_centre)))
  346. else:
  347. self.bg_evening.set_alpha (0)
  348. surface.blit (self.bg_evening, (0, 0))
  349. self.game.screen.blit (surface, (self.x, self.y))
  350. super ().redraw ()
  351. class BgGrass (GameObject):
  352. """
  353. 背景の草原部分
  354. Attributes:
  355. surface (Surface): 草原 Surface
  356. """
  357. surface: Surface
  358. def __init__ (
  359. self,
  360. game: Game,
  361. ):
  362. super ().__init__ (game)
  363. self.game = game
  364. self.surface = pygame.image.load ('./assets/bg-grass.png')
  365. self.surface = pygame.transform.scale (self.surface, (CWindow.WIDTH, CWindow.HEIGHT))
  366. def redraw (
  367. self,
  368. ) -> None:
  369. self.game.screen.blit (self.surface, (self.x, self.y))
  370. super ().redraw ()
  371. class Creature (GameObject):
  372. sound: Sound
  373. def bell (
  374. self,
  375. ) -> None:
  376. self.sound.play ()
  377. class Deerjika (Creature):
  378. """
  379. 伊地知ニジカ
  380. Attributes:
  381. height (int): 高さ (px)
  382. scale (float): 拡大率
  383. surfaces (list[Surface]): ニジカの各フレームを Surface にしたリスト
  384. width (int): 幅 (px)
  385. """
  386. FPS = 30
  387. height: int
  388. scale: float = 1
  389. surfaces: list[Surface]
  390. width: int
  391. talking: bool = False
  392. wav: bytearray | None = None
  393. balloon: Balloon
  394. def __init__ (
  395. self,
  396. game: Game,
  397. pattern: DeerjikaPattern = DeerjikaPattern.NORMAL,
  398. direction: Direction = Direction.LEFT,
  399. layer: float | None = None,
  400. x: float = 0,
  401. y: float = 0,
  402. balloon: Balloon | None = None,
  403. ):
  404. if balloon is None:
  405. raise Exception
  406. super ().__init__ (game, layer, x = x, y = y)
  407. self.pattern = pattern
  408. self.direction = direction
  409. self.balloon = balloon
  410. match pattern:
  411. case DeerjikaPattern.NORMAL:
  412. ...
  413. case DeerjikaPattern.RELAXED:
  414. match direction:
  415. case Direction.LEFT:
  416. self.width = 480
  417. self.height = 270
  418. surface = pygame.image.load ('./assets/deerjika_relax_left.png')
  419. self.surfaces = []
  420. for x in range (0, surface.get_width (), self.width):
  421. self.surfaces.append (
  422. surface.subsurface (x, 0, self.width, self.height))
  423. case Direction.RIGHT:
  424. ...
  425. self.sound = Sound ('./assets/noon.wav')
  426. def redraw (
  427. self,
  428. ) -> None:
  429. surface = self.surfaces[self.frame * self.FPS // FPS % len (self.surfaces)]
  430. if abs (self.scale - 1) > .05:
  431. surface = pygame.transform.scale (
  432. surface, (self.width * self.scale, self.height * self.scale))
  433. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  434. super ().redraw ()
  435. def update (
  436. self,
  437. ) -> None:
  438. if (not self.balloon.enabled) and self.talking:
  439. self.talking = False
  440. if (self.balloon.enabled and self.balloon.frame >= FPS * 1.5
  441. and not self.talking):
  442. self.read_out ()
  443. super ().update ()
  444. def talk (
  445. self,
  446. query: str | None,
  447. answer: str,
  448. ) -> None:
  449. self.bell ()
  450. self._create_wav (answer)
  451. length = 300
  452. if self.wav is not None:
  453. with wave.open ('./nizika_talking.wav', 'rb') as f:
  454. length = int (FPS * (f.getnframes () / f.getframerate () + 4))
  455. self.balloon.talk (query, answer, length = length)
  456. def read_out (
  457. self,
  458. ) -> None:
  459. try:
  460. Sound ('./nizika_talking.wav').play ()
  461. except Exception:
  462. pass
  463. self.talking = True
  464. def _create_wav (
  465. self,
  466. message: str,
  467. ) -> None:
  468. try:
  469. self.wav = Aques.main (message, False)
  470. except Exception:
  471. self.wav = None
  472. if self.wav is None:
  473. return
  474. with open ('./nizika_talking.wav', 'wb') as f:
  475. f.write (self.wav)
  476. class CurrentTime (GameObject):
  477. """
  478. 現在日時表示
  479. Attributes:
  480. font (Font): フォント
  481. """
  482. font: Font
  483. def __init__ (
  484. self,
  485. game: Game,
  486. font: Font,
  487. ):
  488. super ().__init__ (game)
  489. self.font = font
  490. def redraw (
  491. self,
  492. ) -> None:
  493. for i in range (2):
  494. cl = (i * 255, i * 255, i * 255)
  495. self.game.screen.blit (self.font.render (str (self.game.now), True, cl), (-i, -i))
  496. self.game.screen.blit (self.font.render ('%2.3f fps' % self.game.fps, True, cl), (-i, 24 - i))
  497. super ().redraw ()
  498. class Balloon (GameObject):
  499. """
  500. 吹出し
  501. Attributes:
  502. answer (str): 回答テキスト
  503. image_url (str, None): 画像 URL
  504. length (int): 表示する時間 (frame)
  505. query (str, None): 質問テキスト
  506. surface (Surface): 吹出し Surface
  507. x_flip (bool): 左右反転フラグ
  508. y_flip (bool): 上下反転フラグ
  509. """
  510. answer: str = ''
  511. image_url: str | None = None
  512. length: int = 300
  513. query: str | None = None
  514. surface: Surface
  515. x_flip: bool = False
  516. y_flip: bool = False
  517. def __init__ (
  518. self,
  519. game: Game,
  520. x_flip: bool = False,
  521. y_flip: bool = False,
  522. ):
  523. super ().__init__ (game, enabled = False)
  524. self.x_flip = x_flip
  525. self.y_flip = y_flip
  526. self.surface = pygame.transform.scale (pygame.image.load ('./assets/balloon.png'),
  527. (CWindow.WIDTH, CWindow.HEIGHT / 2))
  528. self.surface = pygame.transform.flip (self.surface, self.x_flip, self.y_flip)
  529. def redraw (
  530. self,
  531. ) -> None:
  532. if self.frame >= self.length:
  533. self.enabled = False
  534. self.game.last_answered_at = self.game.now
  535. return
  536. query = self.query
  537. if query and CommonModule.len_by_full (query) > 21:
  538. query = CommonModule.mid_by_full (query, 0, 19.5) + '...'
  539. if query is not None:
  540. query = '>' + query
  541. answer = Surface (
  542. (375, int (((CommonModule.len_by_full (self.answer) - 1) // 16 + 1) * 23.4375)),
  543. pygame.SRCALPHA)
  544. for i in range (int (CommonModule.len_by_full (self.answer) - 1) // 16 + 1):
  545. answer.blit (DEERJIKA_FONT.render (
  546. CommonModule.mid_by_full (self.answer, 16 * i, 16), True, (192, 0, 0)),
  547. (0, 23.4375 * i))
  548. surface = self.surface.copy ()
  549. surface.blit (USER_FONT.render (query, True, (0, 0, 0)), (56.25, 32.8125))
  550. y: float
  551. if self.frame < 30:
  552. y = 0
  553. elif self.frame >= self.length - 90:
  554. y = answer.get_height () - 46.875
  555. else:
  556. y = int ((answer.get_height () - 46.875) * (self.frame - 30) / (self.length - 120))
  557. surface.blit (answer, (46.875, 70.3125), Rect (0, y, 375, 46.875))
  558. self.game.screen.blit (surface, (0, 0))
  559. super ().redraw ()
  560. def talk (
  561. self,
  562. query: str | None,
  563. answer: str,
  564. image_url: str | None = None,
  565. length: int = 300,
  566. ) -> None:
  567. self.query = query
  568. self.answer = answer
  569. self.image_url = image_url
  570. self.length = length
  571. self.frame = 0
  572. self.enabled = True
  573. class KitaSun (GameObject):
  574. """
  575. き太く陽
  576. Attributes:
  577. sun (Sun): ephem の太陽オブゼクト
  578. surface (Surface): き太く陽 Surface
  579. """
  580. alt: float
  581. az: float
  582. sun: Sun
  583. surface: Surface
  584. def __init__ (
  585. self,
  586. game: Game,
  587. ):
  588. super ().__init__ (game)
  589. self.surface = pygame.transform.scale (pygame.image.load ('./assets/sun.png'), (93.75, 93.75))
  590. self.sun = Sun ()
  591. def redraw (
  592. self,
  593. ) -> None:
  594. surface = pygame.transform.rotate (self.surface, -(90 + math.degrees (self.arg)))
  595. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  596. super ().redraw ()
  597. def update (
  598. self,
  599. ) -> None:
  600. self.sun.compute (self.game.sky.observer)
  601. self.alt = self.sun.alt
  602. self.az = self.sun.az
  603. if abs (self.new_arg - self.arg) > math.radians (15):
  604. self.arg = self.new_arg
  605. self.x = self.new_x
  606. self.y = self.new_y
  607. super ().update ()
  608. @property
  609. def new_x (
  610. self,
  611. ) -> float:
  612. return CWindow.WIDTH * (math.degrees (self.az) - 80) / 120
  613. @property
  614. def new_y (
  615. self,
  616. ) -> float:
  617. return ((CWindow.HEIGHT / 2)
  618. - ((CWindow.HEIGHT / 2 + 46.875) * math.sin (self.alt)
  619. / math.sin (math.radians (60))))
  620. @property
  621. def new_arg (
  622. self,
  623. ) -> float:
  624. return math.atan2 (self.new_y - self.y, self.new_x - self.x)
  625. class Jojoko (GameObject):
  626. """
  627. 大月ヨヨコ
  628. Attributes:
  629. base (Surface): 満月ヨヨコ Surface
  630. moon (Moon): ephem の月オブゼクト
  631. surface (Surface): 缺けたヨヨコ
  632. """
  633. alt: float
  634. az: float
  635. base: Surface
  636. moon: Moon
  637. surface: Surface
  638. def __init__ (
  639. self,
  640. game: Game,
  641. ):
  642. super ().__init__ (game)
  643. self.base = pygame.transform.scale (pygame.image.load ('./assets/moon.png'), (93.75, 93.75))
  644. self.moon = Moon ()
  645. self.surface = self._get_surface ()
  646. def redraw (
  647. self,
  648. ) -> None:
  649. self.moon.compute (self.game.sky.observer)
  650. self.alt = self.moon.alt
  651. self.az = self.moon.az
  652. if abs (self.new_arg - self.arg) > math.radians (15):
  653. self.arg = self.new_arg
  654. self.x = self.new_x
  655. self.y = self.new_y
  656. if self.frame % (FPS * 3600) == 0:
  657. self.surface = self._get_surface ()
  658. surface = pygame.transform.rotate (self.surface, -(90 + math.degrees (self.arg)))
  659. surface.set_colorkey ((0, 255, 0))
  660. self.game.screen.blit (surface, surface.get_rect (center = (self.x, self.y)))
  661. super ().redraw ()
  662. @property
  663. def phase (
  664. self,
  665. ) -> float:
  666. dt: datetime = ephem.localtime (ephem.previous_new_moon (self.game.sky.observer.date))
  667. return (self.game.now - dt).total_seconds () / 60 / 60 / 24
  668. def _get_surface (
  669. self,
  670. ) -> Surface:
  671. """
  672. ヨヨコを月齢に応じて缺かす.
  673. Returns:
  674. Surface: 缺けたヨヨコ
  675. """
  676. jojoko = self.base.copy ()
  677. for i in range (200):
  678. if 1 <= self.phase < 15:
  679. pygame.gfxdraw.bezier (jojoko, ((0, (100 + i) * .468_75), (46.875, (180 * self.phase / 7 - 80 + i) * .468_75), (93.75, (100 + i) * .468_75)), 3, (0, 255, 0))
  680. elif self.phase < 16:
  681. pass
  682. elif self.phase < 30:
  683. pygame.gfxdraw.bezier (jojoko, ((0, (100 - i) * .468_75), (46.875, (180 * (self.phase - 15) / 7 - 80 - i) * .468_75), (93.75, (100 - i) * .468_75)), 3, (0, 255, 0))
  684. else:
  685. jojoko.fill ((0, 255, 0))
  686. return jojoko
  687. @property
  688. def new_x (
  689. self,
  690. ) -> float:
  691. return CWindow.WIDTH * (math.degrees (self.az) - 80) / 120
  692. @property
  693. def new_y (
  694. self,
  695. ) -> float:
  696. return ((CWindow.HEIGHT / 2)
  697. - ((CWindow.HEIGHT / 2 + 46.875) * math.sin (self.alt)
  698. / math.sin (math.radians (60))))
  699. @property
  700. def new_arg (
  701. self,
  702. ) -> float:
  703. return math.atan2 (self.new_y - self.y, self.new_x - self.x)
  704. class Sky:
  705. """
  706. 天体に関する情報を保持するクラス
  707. Attributes:
  708. observer (Observer): 観測値
  709. """
  710. observer: Observer
  711. class CWindow:
  712. """
  713. ウィンドゥに関する定数クラス
  714. Attributes:
  715. WIDTH (int): ウィンドゥ幅
  716. HEIGHT (int): ウィンドゥ高さ
  717. """
  718. WIDTH = 480
  719. HEIGHT = 360
  720. class Broadcast:
  721. chat: PytchatCore
  722. code: str
  723. def __init__ (
  724. self,
  725. broadcast_code: str,
  726. ):
  727. self.code = broadcast_code
  728. self.chat = pytchat.create (self.code)
  729. def fetch_chat (
  730. self,
  731. ) -> Chat | None:
  732. if not self.chat.is_alive ():
  733. self.chat = pytchat.create (self.code)
  734. return None
  735. chats = self.chat.get ().items
  736. if not chats:
  737. return None
  738. return random.choice (chats)
  739. class Video (GameObject):
  740. fps: int
  741. pausing: bool = False
  742. sound: Sound | None
  743. surfaces: list[Surface]
  744. def __init__ (
  745. self,
  746. game: Game,
  747. path: str,
  748. ):
  749. super ().__init__ (game)
  750. self.pausing = False
  751. (self.surfaces, self.fps) = self._create_surfaces (path)
  752. self.sound = self._create_sound (path)
  753. self.stop ()
  754. def _create_sound (
  755. self,
  756. path: str,
  757. ) -> Sound | None:
  758. bytes_io = BytesIO ()
  759. try:
  760. from pydub import AudioSegment
  761. audio = AudioSegment.from_file (path, format = path.split ('.')[-1])
  762. except ModuleNotFoundError:
  763. return None
  764. audio.export (bytes_io, format = 'wav')
  765. bytes_io.seek (0)
  766. return pygame.mixer.Sound (bytes_io)
  767. def _create_surfaces (
  768. self,
  769. path: str,
  770. ) -> tuple[list[Surface], int]:
  771. cap = self._load (path)
  772. surfaces: list[Surface] = []
  773. if cap is None:
  774. return ([], FPS)
  775. fps = int (cap.get (cv2.CAP_PROP_FPS))
  776. while cap.isOpened ():
  777. frame = self._read_frame (cap)
  778. if frame is None:
  779. break
  780. surfaces.append (self._convert_to_surface (frame))
  781. new_surfaces: list[Surface] = []
  782. for i in range (len (surfaces) * FPS // fps):
  783. new_surfaces.append (surfaces[i * fps // FPS])
  784. return (new_surfaces, fps)
  785. def _load (
  786. self,
  787. path: str,
  788. ) -> VideoCapture | None:
  789. """
  790. OpenCV で動画を読込む.
  791. """
  792. cap = VideoCapture (path)
  793. if cap.isOpened ():
  794. return cap
  795. return None
  796. def _read_frame (
  797. self,
  798. cap: VideoCapture,
  799. ) -> np.ndarray | None:
  800. """
  801. 動画のフレームを読込む.
  802. """
  803. ret: bool
  804. frame: np.ndarray
  805. (ret, frame) = cap.read ()
  806. if ret:
  807. return frame
  808. return None
  809. def _convert_to_surface (
  810. self,
  811. frame: np.ndarray,
  812. ) -> Surface:
  813. frame = cv2.cvtColor (frame, cv2.COLOR_BGR2RGB)
  814. frame_surface = pygame.surfarray.make_surface (frame)
  815. frame_surface = pygame.transform.rotate (frame_surface, -90)
  816. frame_surface = pygame.transform.flip (frame_surface, True, False)
  817. return frame_surface
  818. def play (
  819. self,
  820. ) -> None:
  821. self.enabled = True
  822. self.pausing = False
  823. if self.sound is not None:
  824. self.sound.play ()
  825. def stop (
  826. self,
  827. ) -> None:
  828. self.enabled = False
  829. self.frame = 0
  830. def pause (
  831. self,
  832. ) -> None:
  833. self.pausing = True
  834. def redraw (
  835. self,
  836. ) -> None:
  837. surface = pygame.transform.scale (self.surfaces[self.frame], (self.width, self.height))
  838. self.game.screen.blit (surface, (self.x, self.y))
  839. super ().redraw ()
  840. def update (
  841. self,
  842. ) -> None:
  843. if self.frame >= len (self.surfaces) - 1:
  844. self.stop ()
  845. if self.pausing:
  846. self.frame -= 1
  847. super ().update ()
  848. class NicoVideo (Video):
  849. def __init__ (
  850. self,
  851. game: Game,
  852. video_code: str | None = None,
  853. ):
  854. if video_code is None:
  855. super ().__init__ (game, './assets/snack_time.mp4')
  856. else:
  857. try:
  858. comments = fetch_comments (video_code)
  859. fetch_nico_video (video_code, comments)
  860. super ().__init__ (game, './outputs/output.mp4')
  861. except Exception as ex:
  862. print (ex)
  863. super ().__init__ (game, './assets/snack_time.mp4')
  864. (self.width, self.height) = (CWindow.HEIGHT * 16 // 9, CWindow.HEIGHT)
  865. (self.x, self.y) = ((CWindow.WIDTH - self.width) / 2, 0)
  866. class SnackTime (Video):
  867. def __init__ (
  868. self,
  869. game: Game,
  870. ):
  871. super ().__init__ (game, './assets/snack_time.mp4')
  872. (self.width, self.height) = (CWindow.HEIGHT * 16 // 9, CWindow.HEIGHT)
  873. (self.x, self.y) = ((CWindow.WIDTH - self.width) / 2, 0)
  874. def fetch_bytes_from_url (
  875. url: str,
  876. ) -> bytes | None:
  877. res = requests.get (url, timeout = 60)
  878. if res.status_code != 200:
  879. return None
  880. return res.content
  881. def fetch_nico_video (
  882. video_code: str,
  883. comments: list[CommentDict],
  884. ) -> None:
  885. client = NicoNico ()
  886. watch_data = client.video.watch.get_watch_data (video_code)
  887. outputs = client.video.watch.get_outputs (watch_data)
  888. output_label = next (iter (outputs))
  889. downloaded_path = client.video.watch.download_video (
  890. watch_data, output_label, '%(title)s.%(ext)s')
  891. os.rename (downloaded_path, './outputs/base.mp4')
  892. ass = video.build_ass (cast (Any, comments), 640, 480)
  893. with open ('./outputs/comments.ass', 'w', encoding = 'utf-8') as f:
  894. f.write (ass)
  895. subprocess.run (
  896. ['ffmpeg',
  897. '-i', './outputs/base.mp4',
  898. '-vf', 'ass=./outputs/comments.ass',
  899. '-c:v', 'libx264',
  900. '-crf', '18',
  901. '-preset', 'veryfast',
  902. '-c:a', 'copy',
  903. './outputs/output.mp4',
  904. '-y'],
  905. check = True)
  906. def fetch_comments (
  907. video_code: str,
  908. ) -> list[CommentDict]:
  909. result = subprocess.run (
  910. ['python3', 'get_comments_by_video_code.py', video_code],
  911. cwd = NIZIKA_NICO_DIR,
  912. env = os.environ,
  913. capture_output = True,
  914. text = True)
  915. return cast(list[CommentDict], json.loads (result.stdout))
  916. def add_query (
  917. broadcast: Broadcast,
  918. ) -> None:
  919. chat = broadcast.fetch_chat ()
  920. if chat is None:
  921. return
  922. DB.begin_transaction ()
  923. chat.message = emoji.emojize (chat.message)
  924. user = (User.where ('platform', Platform.YOUTUBE.value)
  925. .where ('code', chat.author.channelId)
  926. .first ())
  927. if user is None:
  928. user = User ()
  929. user.platform = Platform.YOUTUBE.value
  930. user.code = chat.author.channelId
  931. user.name = chat.author.name
  932. user.icon = fetch_bytes_from_url (chat.author.imageUrl)
  933. user.save ()
  934. query = Query ()
  935. query.user_id = user.id
  936. query.target_character = Character.DEERJIKA.value
  937. query.content = chat.message
  938. query.query_type = QueryType.YOUTUBE_COMMENT.value
  939. query.model = GPTModel.GPT3_TURBO.value
  940. query.sent_at = datetime.now ()
  941. query.answered = False
  942. query.save ()
  943. DB.commit ()
  944. class CommentDict (TypedDict):
  945. content: str
  946. vpos_ms: int
  947. def log (
  948. msg: str,
  949. ) -> None:
  950. print (f"[{ datetime.now ().isoformat (sep = ' ', timespec = 'seconds') }] { msg }",
  951. flush = True)
  952. if __name__ == '__main__':
  953. main ()