2024年8月にPRTimesさん主催のハッカソンに参加させていただきました。今回の記事はそのとき開発した成果物の解説になります✨
解説は私が担当したバックエンド領域のみなります。フロントエンドを含む全体のリポジトリをご覧になられたい方はこちらのgithubリンクを参照ください🙌
以下、章立てになります。
企画立案
ハッカソンのお題は「〇〇にPRTimesをもっと使ってもらえるにはどうすればいいか」というものでした。
5W1Hを考えながらアイデアの焦点を絞りました。
- who(to company) : いろんな企業
- what : 動画に載せて商品を簡単に、早く、安く
- why : 時間がない人や大衆に伝えたい
- where : SNSを媒体として地域関係なく
- who(to user) : 一般人
- (when)
- (how)
アイデア出しの段階では、学生起業家や地方企業といった狭いターゲット層向けのサービスも候補として挙がりましたが、私たち自身がそのペルソナに当てはまらず、3日間という短期間で高いクオリティの成果物を生み出すことが難しいと判断しました。そのため、一般向けのサービスに方向転換し、ターゲット層を広げることで全体のパイを拡大する方針に定めました。
私たちのチームは「記事から誰でも手軽にショート動画を生成できるアプリケーション」の開発を目標としました。
ディレクトリ構成
hackathon2024-summer-team-c/backend/workaround
├── test-api
│ ├── main.py
│ ├── test-request.py
├── test-movie
| ├── test_movie.new.py
| ├── rule99.png
| └── aogurade-tx2.png
~~~~~~~~その他省略~~~~~~~~~
以下は
- main.py
- test-request.py
- test_movie.new.py
の3つのpythonファイルのコード解説になります。
実行環境
- Python 3.9.6
- fastapi
- moviepy
ソースコード
ファイルの概要
main.pyはFastAPIを使ってAPIエンドポイントを定義しており、APIサーバーを提供します。
test-request.pyはそのサーバーに対してテストリクエストを送信する役割を持っています。
test_movie.new.pyは動画の自動生成を行います。
main.py
- 必要なライブラリのインポート
from fastapi import FastAPI # Web APIを構築するライブラリ
from pydantic import BaseModel # データモデルの定義とバリデーションを行うライブラリ
- FastAPIアプリのインスタンス生成
app = FastAPI()
- GETエンドポイントの定義
ここは情報を取得しているだけなのでGETメソッドが適しています。
# サーバーのルート(/)にアクセスされた際、{"Hello":"World"}というJSONメッセージを返す。
@app.get("/")
def read_root():
return {"Hello": "World"}
# /items/{item_id}というパスで実行される。item_idは動的パラメータで整数値を、qは任意の文字列を受け取り、JSON形式を返す。
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
return {"item_id": item_id, "q": q}
- データモデルの定義
# BaseModelを継承することで、各フィールドに対して型チェックが行われる。例えば、titleに数字を渡すと自動的にエラーが発生する。
class Article(BaseModel):
title: str
subtitle: str
content: str
class Subtitle(BaseModel):
id: int
content: str
class Subtitles(BaseModel):
subtitles: list[str]
- POSTエンドポイントの定義
ここはクライアントからサーバーへデータを送信し、新しいリソースを作成しているのでPOSTメソッドが適しています。
# /subtitles/エンドポイントへのPOSTリクエストを処理する。非同期関数として定義され、Articleモデルのデータを受け取り、そのまま返す。
@app.post("/subtitles/")
async def create_subtitles(article: Article):
return article
# /video/エンドポイントも同様に非同期関数として定義される。
@app.post("/video/")
async def create_video(subtitles: Subtitles):
return subtitles
test-request.py
- 必要なライブラリのインポート
import json # PythonオブジェクトとJSON形式のデータとの相互変換を行うライブラリ
import requests # HTTPリクエストを簡単に行うためのライブラリ
- POSTリクエストの送信 - /subtitlesエンドポイント
# article辞書を作成し、記事の情報を格納
article = {
"title":"タイトル",
"subtitle":"サブタイトル",
"content": "内容"
}
# 辞書をJSON形式の文字列に変換
json_data = json.dumps(article)
# POSTメソッドでリクエストを送信、データはjsonパラメータに指定。Content-Typeヘッダーをapplication/jsonに設定。
response = requests.post(
"http://127.0.0.1:8000/subtitles",
data=json_data,
headers={"Content-Type": "application/json"}
)
print(response.status_code)
print(response.text)
- POSTリクエストの送信 - /video/エンドポイント
# subtitles辞書を作成し、サブタイトルのリストを格納
subtitles = {
"subtitles": [
"サブタイトル1",
"サブタイトル2",
"サブタイトル3"
]
}
# 辞書をJSON形式の文字列に変換
json_data = json.dumps(subtitles)
# POSTメソッドでリクエストを送信、データはjsonパラメータに指定。Content-Typeヘッダーをapplication/jsonに設定
response = requests.post(
"http://127.0.0.1:8000/video/",
data=json_data,
headers={"Content-Type": "application/json"}
)
print("\nResponse for video endpoint:")
print(response.status_code)
print(response.json())
test_movie.new.py
- 必要なライブラリのインポート
from moviepy.editor import (
ImageClip,
AudioFileClip,
TextClip,
CompositeVideoClip,
CompositeAudioClip,
concatenate_audioclips
)
- 関数の定義
# この関数では画像(サムネイル)ファイル、背景動画ファイル、字幕リストを受け取り、それらを組み合わせて動画を作るための処理が行われる。
def generate_movie(image_path: str, background_path: str, subtitles: list):
- 背景画像の読み込み、リサイズ
背景画像のリサイズは縦幅のみ行いました。ショート動画は縦長のアスペクト比「9:16」なのでこれより横長の背景画像であれば縦幅に合わせることで余白をなくすことができます。
# 背景画像を読み込む
background_clip = ImageClip(background_path)
# 背景画像を動画の縦幅のみを基準にリサイズ
background_clip = background_clip.resize(height=1920)
- 画像(サムネイル)の読み込み、リサイズ
# 画像を読み込む
image_clip = ImageClip(image_path)
# 画像を動画の横幅に合わせてリサイズ、縦幅が超えている場合は縦幅も調整
image_clip = image_clip.resize(width=1080)
if image_clip.h > 1920:
image_clip = image_clip.resize(height=1920)
- サブタイトル用のオーディオクリップを生成
# サブタイトル用のオーディオクリップを生成
audio_clips = [AudioFileClip(f"speech{i}.mp3") for i in range(len(subtitles))] # AudioFileClipを使って、音声ファイルを読み込み、それぞれのサブタイトルに対応するオーディオクリップを生成する。
audio_durations = [audio_clip.duration for audio_clip in audio_clips] # audio_durationsというリストに各 audio_clipの持続時間(秒数)をリスト形式で格納する。
audio_durations = [0] + audio_durations # 各字幕や音声クリップの開始タイミングを計算しやすくしている。
speeches = concatenate_audioclips(audio_clips) # すべてのオーディオクリップを連結して1つの長い音声クリップ speeches として作成する。
- 画像クリップ、背景クリップに音声を追加し、持続時間を設定
# 画像クリップに音声を追加し、持続時間を設定
image_clip = image_clip.set_audio(speeches)
image_clip = image_clip.set_duration(speeches.duration)
# 背景クリップに音声を追加し、持続時間を設定
background_clip = background_clip.set_audio(speeches)
background_clip = background_clip.set_duration(speeches.duration)
- 字幕クリップの生成
# サブタイトルクリップを保存するリスト
subtitle_clips = [] # 空のリストsubtitle_clipsを作成
for i, subtitle in enumerate(subtitles): # for文を用いて字幕クリップを順番に格納
subtitle_clip = TextClip(
subtitle, font="./NotoSansJP-Regular.otf", fontsize=50, color="white"
) # フォント、フォントサイズ、文字色を指定
subtitle_clip = subtitle_clip.set_duration(audio_durations[i + 1]).set_start(
sum(audio_durations[:i + 1])
) # 持続時間と開始時間を設定。ただし、audio_durationsリストの先頭は基準となる0が含まれているため、i+1を用いている。
subtitle_clip = subtitle_clip.set_position(("center", 0.1), relative=True) # 表示位置を設定
subtitle_clips.append(subtitle_clip)
- 背景クリップ、画像クリップ、字幕クリップの合成と保存
CompositeVideoClipを使う際、クリップの順番が配置の順番になるため、注意が必要です。また、今回のプロダクトのショート動画は画像を組み合わせて作るため、「動画が滑らかである」という要素よりは、処理速度の速さを重視したため、fps値を小さくとりました。
# 背景クリップ、画像クリップ、字幕クリップの合成
final_clip = CompositeVideoClip([background_clip, image_clip.set_position("center")] + subtitle_clips, size = (1080,1920))
# 最終クリップをファイルに書き出す
final_clip.write_videofile("video.mp4", fps=2)
- 最初に定義した関数を呼び出して動画を生成
本来フロントエンドから引き渡されたものを入力しますが、今回はフロントエンドからバックエンドへの通信がうまくいかず、手入力により画像(サムネイル)ファイル、字幕リストを入力しました。
# 関数を呼び出して動画を生成
generate_movie(
"d112-1078-443228-0.jpg", "aogurade-tx2.png",
[
"4月1日、夢を発信する日。",
"1100社以上が参加表明!",
"ウソではなく、夢で驚かせよう。",
"あなたの夢が、誰かの希望に。",
"さあ、ヒーローになって夢を語ろう!"
]
)
最後に
最終的な成果物としては(フロントエンドとバックエンドの通信に問題がない場合)PRTimesの記事を1つ選んでくるだけで、自動でその記事から画像と文章を抽出し、AIが最適なメッセージを考え、音声の入った15秒程度のショート動画を作るアプリケーションを開発することができました!
初めてのハッカソンでしたが、スタッフの皆様やチームのメンバーにサポートして頂き、とても楽しく貴重な経験を積むことができました。ありがとうございました😊