はじめに
こちらは長野高専 Advent Calendar 2023 17日目の記事です!
@shun-shobon さんが開催するNNCTアドカレもこれで3回目の参加ですね〜。現在後続の主催者を探しているみたいなので、来年度以降も在籍予定の方はぜひ!
自己紹介
卒業してしまった身なので書いておきます!
18sで昨年電子情報工学科を卒業したBony_Chopsと申します!現在は大学に3年次編入して、各所でインターン等々をやっています。
明日は月曜日
長野高専電子情報工学科18生のDiscord鯖には 月曜日を楽しく迎えるため に作られた #明日は月曜日 というチャンネルがあります
このチャンネルでは、 明日は月曜日bot が 毎週日曜21:00に陽気になれる画像を投稿 します。詳細はNNCT18J 元ネタ集をご覧ください。
このbotは2022/1月に誕生し、2023/11月までの2年10ヶ月間、97回メンバーに明日が月曜日であることをお知らせしました。
ただ、このbotの問題点として、 毎週同じ画像が投稿 されます。これではあまり陽気になれませんよね。今回は、 動的に生成する動画 を使って、鯖メンバーを喜ばせようと思います!
技術制定
今回作るbotはこんな感じの構成にしようと思います。
動画を作る
まずは応援する動画を作ります。今回は、「カレンダーの明日(月曜日)にズームし、その枠の中で猫がAviciiのWaiting for Loveを歌う1」という内容にしようと思います。
Remotion
まずはRemotionで動画を作っていきます。RemotionはReactで動画を作成するツールで、今回のような動的に動画を作成するケースに置いて非常に有用です。
カレンダーをcanvasで作る
動画を作るに当たり、カレンダーを用意しなければいけません。当初はFullCalendarあたりを使おうと思っていましたが、うまく描画できないため、canvasでフルスクラッチすることにしました。
とは言っても自力で作るのは大変なのでChatGPTにすべてやらせました。退屈なことはChatGPTにやらせよう2。
drawCalendar.ts
export function drawCalendar(
canvas: HTMLCanvasElement,
date: {year: number; month: number; day: number},
size: {width: number; height: number},
cat?: { // 猫の動画のcanvas要素
src: CanvasImageSource;
opacity: number;
},
holidayOpacity: number = 0 // ここの値を0->1にすることで、徐々に日付が"休"の文字に変化する(後述)
): {x: number; y: number} /* 指定したdate.dayの位置、動画のズームで使う */ | null {
// Canvas要素を取得
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// カレンダーの設定
const days = ['日', '月', '火', '水', '木', '金', '土'];
// 今月の情報を取得(例として2023年10月)
const firstDay = new Date(date.year, date.month - 1, 1).getDay();
// 翌月の初日を取得
const firstDayOfNextMonth: Date = new Date(date.year, date.month, 1);
// 翌月の初日のミリ秒を取得し、1ミリ秒引く
const lastDayOfMonthTime: number = firstDayOfNextMonth.getTime() - 1;
// ミリ秒からDateオブジェクトを作成
const lastDayOfMonth: Date = new Date(lastDayOfMonthTime);
const totalDays = lastDayOfMonth.getDate();
// Canvasのサイズを指定されたサイズに設定
canvas.width = size.width;
canvas.height = size.height;
// セルのサイズを計算(全体の幅を7で割る)
const cellWidth = size.width / 7;
// 全体の高さから曜日のヘッダ分を引いたものを、(最大)6で割る
const cellHeight = (size.height - cellWidth) / 6;
// カレンダーの描画処理
// 曜日のヘッダーを描画
ctx.font = `${cellWidth / 3}px Arial`; // フォントサイズをセルの幅に合わせて調整
days.forEach((day, i) => {
ctx.fillStyle = i === 0 ? 'red' : i === 6 ? 'blue' : 'black'; // 日曜日は赤、土曜日は青、それ以外は黒
ctx.fillText(
day,
cellWidth * i + cellWidth / 2 - ctx.measureText(day).width / 2,
cellWidth / 2
);
});
// 日付を描画する前に最終行が必要かどうかを確認
let needsLastRow = true;
// 日付を描画
let day = 1;
let tx: number;
let ty: number;
for (let i = 0; i < 6; i++) {
// 最大で6週間
for (let j = 0; j < 7; j++) {
const x = cellWidth * j;
const y = cellWidth + cellHeight * i;
if (i === 0 && j < firstDay) {
continue; // 月の最初の日までセルを空にする
}
if (day > totalDays) {
break; // 月の最後の日を過ぎたら終了
}
// 曜日に応じて色を変更
ctx.fillStyle = j === 0 ? 'red' : j === 6 ? 'blue' : 'black'; // 日曜日は赤、土曜日は青、それ以外は黒
if (day === date.day) {
tx = x + cellWidth / 2;
ty = y + cellHeight / 2;
// 日付をセンタリングして描画
ctx.globalAlpha = 1 - holidayOpacity;
const dayString = day.toString();
ctx.fillText(
dayString,
x + cellWidth / 2 - ctx.measureText(dayString).width / 2,
y + cellHeight / 2 + cellWidth / 6
);
// 日付をセンタリングして描画
ctx.globalAlpha = holidayOpacity;
ctx.fillStyle = 'red';
const holidayString = '休';
ctx.fillText(
holidayString,
x + cellWidth / 2 - ctx.measureText(holidayString).width / 2,
y + cellHeight / 2 + cellWidth / 6
);
ctx.globalAlpha = 1;
ctx.fillStyle = 'black';
if (cat) {
ctx.globalAlpha = cat.opacity ?? 1;
ctx.drawImage(
cat.src,
x + cellWidth / 2 - cellWidth / 3,
y + cellHeight / 2 - cellWidth / 3,
(cellWidth * 2) / 3,
(cellWidth * 2) / 3
);
ctx.globalAlpha = 1;
}
} else {
// 日付をセンタリングして描画
const dayString = day.toString();
ctx.fillText(
dayString,
x + cellWidth / 2 - ctx.measureText(dayString).width / 2,
y + cellHeight / 2 + cellWidth / 6
);
}
day++;
}
if (day > totalDays) {
needsLastRow = false; // 最終行が不要であることを示す
break;
}
}
// 罫線を描画する関数
function drawGrid(ctx: CanvasRenderingContext2D, needsLastRow: boolean) {
ctx.beginPath();
// 縦の罫線の長さを調整するために最終行が必要かどうかに応じた値を計算
const gridHeight = needsLastRow ? size.height : 6 * cellHeight;
for (let i = 0; i <= 7; i++) {
// 縦の罫線
ctx.moveTo(i * cellWidth, 0);
ctx.lineTo(i * cellWidth, gridHeight);
}
for (let i = 0; i <= 6; i++) {
// 横の罫線(7日分 + ヘッダー1行)
ctx.moveTo(0, i * cellHeight);
ctx.lineTo(size.width, i * cellHeight);
}
if (needsLastRow) {
ctx.moveTo(0, 7 * cellHeight);
ctx.lineTo(size.width, 7 * cellHeight);
}
ctx.strokeStyle = 'black';
ctx.stroke();
}
// 罫線の描画
drawGrid(ctx, needsLastRow);
return {x: tx!, y: ty!};
}
祝日を祝う
月曜日だけではなく、せっかくなので 祝日を祝う 機能も作ります。
下記を参考に、canvasで描画する部分を組み込みます。
draw50ch.ts
export function draw50ch(
canvas: HTMLCanvasElement,
text: string,
text2: string
) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.font = '100px Noto Sans JP';
ctx.lineJoin = 'round';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(1, 0, -0.4, 1, 0, 0);
const posx = 70;
const posy = 100;
// 黒色
ctx.strokeStyle = '#000';
ctx.lineWidth = 22;
ctx.strokeText(text, posx + 4, posy + 4);
// 銀色
{
const grad = ctx.createLinearGradient(0, 24, 0, 122);
grad.addColorStop(0.0, 'rgb(0,15,36)');
grad.addColorStop(0.1, 'rgb(255,255,255)');
grad.addColorStop(0.18, 'rgb(55,58,59)');
grad.addColorStop(0.25, 'rgb(55,58,59)');
grad.addColorStop(0.5, 'rgb(200,200,200)');
grad.addColorStop(0.75, 'rgb(55,58,59)');
grad.addColorStop(0.85, 'rgb(25,20,31)');
grad.addColorStop(0.91, 'rgb(240,240,240)');
grad.addColorStop(0.95, 'rgb(166,175,194)');
grad.addColorStop(1, 'rgb(50,50,50)');
ctx.strokeStyle = grad;
ctx.lineWidth = 20;
ctx.strokeText(text, posx + 4, posy + 4);
}
// 黒色
ctx.strokeStyle = '#000000';
ctx.lineWidth = 16;
ctx.strokeText(text, posx, posy);
// 金色
{
const grad = ctx.createLinearGradient(0, 20, 0, 100);
grad.addColorStop(0, 'rgb(253,241,0)');
grad.addColorStop(0.25, 'rgb(245,253,187)');
grad.addColorStop(0.4, 'rgb(255,255,255)');
grad.addColorStop(0.75, 'rgb(253,219,9)');
grad.addColorStop(0.9, 'rgb(127,53,0)');
grad.addColorStop(1, 'rgb(243,196,11)');
ctx.strokeStyle = grad;
ctx.lineWidth = 10;
ctx.strokeText(text, posx, posy);
}
// 黒
ctx.lineWidth = 6;
ctx.strokeStyle = '#000';
ctx.strokeText(text, posx + 2, posy - 3);
// 白
ctx.lineWidth = 6;
ctx.strokeStyle = '#FFFFFF';
ctx.strokeText(text, posx, posy - 3);
// 赤
{
const grad = ctx.createLinearGradient(0, 20, 0, 100);
grad.addColorStop(0, 'rgb(255, 100, 0)');
grad.addColorStop(0.5, 'rgb(123, 0, 0)');
grad.addColorStop(0.51, 'rgb(240, 0, 0)');
grad.addColorStop(1, 'rgb(5, 0, 0)');
ctx.lineWidth = 1;
ctx.strokeStyle = grad;
ctx.strokeText(text, posx, posy - 3);
}
// 赤
{
const grad = ctx.createLinearGradient(0, 20, 0, 100);
grad.addColorStop(0, 'rgb(230, 0, 0)');
grad.addColorStop(0.5, 'rgb(123, 0, 0)');
grad.addColorStop(0.51, 'rgb(240, 0, 0)');
grad.addColorStop(1, 'rgb(5, 0, 0)');
ctx.fillStyle = grad;
ctx.fillText(text, posx, posy - 3);
}
drawBottom(ctx, text2);
}
function drawBottom(ctx: CanvasRenderingContext2D, text: string): number {
ctx.measureText(text);
const x = 800 + 70 - ctx.measureText(text).width;
const y = 220;
ctx.setTransform(1, 0, -0.45, 1, 0, 0);
// 黒色
ctx.strokeStyle = '#000';
ctx.lineWidth = 22;
ctx.strokeText(text, x + 5, y + 2);
// 銀
{
const grad = ctx.createLinearGradient(0, y - 80, 0, y + 18);
grad.addColorStop(0, 'rgb(0,15,36)');
grad.addColorStop(0.25, 'rgb(250,250,250)');
grad.addColorStop(0.5, 'rgb(150,150,150)');
grad.addColorStop(0.75, 'rgb(55,58,59)');
grad.addColorStop(0.85, 'rgb(25,20,31)');
grad.addColorStop(0.91, 'rgb(240,240,240)');
grad.addColorStop(0.95, 'rgb(166,175,194)');
grad.addColorStop(1, 'rgb(50,50,50)');
ctx.strokeStyle = grad;
ctx.lineWidth = 19;
ctx.strokeText(text, x + 5, y + 2);
}
// 黒色
ctx.strokeStyle = '#10193A';
ctx.lineWidth = 17;
ctx.strokeText(text, x, y);
// 白
ctx.strokeStyle = '#DDD';
ctx.lineWidth = 8;
ctx.strokeText(text, x, y);
// 紺
{
const grad = ctx.createLinearGradient(0, y - 80, 0, y);
grad.addColorStop(0, 'rgb(16,25,58)');
grad.addColorStop(0.03, 'rgb(255,255,255)');
grad.addColorStop(0.08, 'rgb(16,25,58)');
grad.addColorStop(0.2, 'rgb(16,25,58)');
grad.addColorStop(1, 'rgb(16,25,58)');
ctx.strokeStyle = grad;
ctx.lineWidth = 7;
ctx.strokeText(text, x, y);
}
// 銀
{
const grad = ctx.createLinearGradient(0, y - 80, 0, y);
grad.addColorStop(0, 'rgb(245,246,248)');
grad.addColorStop(0.15, 'rgb(255,255,255)');
grad.addColorStop(0.35, 'rgb(195,213,220)');
grad.addColorStop(0.5, 'rgb(160,190,201)');
grad.addColorStop(0.51, 'rgb(160,190,201)');
grad.addColorStop(0.52, 'rgb(196,215,222)');
grad.addColorStop(1.0, 'rgb(255,255,255)');
ctx.fillStyle = grad;
ctx.fillText(text, x, y - 3);
}
return ctx.measureText(text).width + 30;
}
生成結果1
祝日を祝う機能3
処理部を作る
Cloud Runで動かすに当たって、HTTPリクエストを受け付ける必要があります。RemotionをNode.jsで動かしているため、Fastify等でやっても良かったのですが、node_modules
をこれ以上でかくしたくなかったので、Goで書きます(?)4
レンダリングは結構時間がかかるので、Validation等の作業が正常に完了した時点でgoroutineとしての実行に移行し、先に204レスポンスを返します。
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/BonyChops/monday-left-me-broken-bot/server/internal/utils"
"github.com/go-playground/validator"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
)
type (
Body struct {
// ...
}
CustomValidator struct {
validator *validator.Validate
}
)
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return nil
}
func main() {
godotenv.Load()
e := echo.New()
e.Validator = &CustomValidator{validator: validator.New()}
e.POST("/judge", func(c echo.Context) error {
body := Body{}
if err := c.Bind(&body); err != nil {
return echo.NewHTTPError(http.StatusBadRequest)
}
if err := c.Validate(body); err != nil {
log.Error(err)
return err
}
events, err := utils.GetHolidays(body.HolidayDataUrl)
if err != nil {
log.Error(err)
return c.JSON(http.StatusInternalServerError, "Internal Server Error")
}
format, year, month, day := utils.GetDateFormat(body.Date.Year, body.Date.Month, body.Date.Day)
afterResponse := func(val string) {
log.Info("Rendering...")
renderedFileName, err := utils.RenderProcess(year, month, day, val)
if err != nil {
log.Error(err)
return
}
url, err := utils.UploadVideo(filepath.Join(os.Getenv("RENDER_MJS_DIR_PATH"), "out", renderedFileName), os.Getenv("IMGUR_CLIENT_ID"))
if err != nil {
log.Error(err)
return
}
time.Sleep(5 * time.Second)
utils.SendDiscord(url, body.DiscordUrl)
}
if val, ok := events[format]; ok {
fmt.Printf("holiday event exists. The value is %#v\n", val)
go afterResponse(val)
} else if body.Force || (utils.GetWeekDayFromDate(year, month, day) == time.Monday) {
go afterResponse("")
} else {
log.Info("Not Monday")
}
return c.NoContent(http.StatusNoContent)
})
e.Logger.Fatal(e.Start(":8080"))
}
祝日のデータは、内閣府がCSVで公開しているのでそれを使います。
CSVはShift-JISなので注意
package utils
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/transform"
)
type Events map[string]string
func GetHolidays(holidayDataUrl string) (Events, error) {
resp, err := http.Get(holidayDataUrl)
if err != nil {
return nil, err
}
defer resp.Body.Close()
reader := transform.NewReader(resp.Body, japanese.ShiftJIS.NewDecoder())
byteArray, _ := io.ReadAll(reader)
eventStrings := strings.Split(string(byteArray), "\n")
events := Events{}
for _, eventString := range eventStrings {
if eventString == "" {
continue
}
event := strings.Split(eventString, ",")
events[event[0]] = event[1]
}
return events, nil
}
Dockerコンテナ化
Cloud Run で動かすため、Dockerfile
を書きます。Remotionが公開している推奨構成を参考にしながら、こんな感じにしてみました5。
# Goのサーバーを先にビルド
FROM golang:1.21-alpine3.18 as builder
WORKDIR /app
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server .
RUN go build -o server cmd/server.go
# 動かす本体
FROM node:18-bookworm
WORKDIR /app
RUN apt-get update
RUN apt-get install -y chromium fonts-noto-cjk
COPY package.json package*.json yarn.lock* pnpm-lock.yaml* bun.lockb* tsconfig.json* remotion.config.* ./
COPY src ./src
COPY --from=builder /app/server .
RUN npm ci
COPY render.mjs render.mjs
CMD ["/app/server"] # サーバー起動
詰まる
あとはCloud Runに上げるだけ...なのですが、curlで叩いてみても何故か一向にレンダリングが始まりません...
Stdoutが出ていなかった(実行はされていた)
cmd := exec.Command("node", filepath.Join(os.Getenv("RENDER_MJS_DIR_PATH"), "render.mjs"))
// ...
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Nodeでの実行をこのようにして表示していたのですが、どうやらこの方法だとGoogle Cloudでログとして拾ってくれないみたいです。自分は実行されていないと勘違いして色々試していましたが、Discordの方を確認するとちゃんとできていました。
結果
以前のbotを止めることができなく、botが増えました6。