Diffusersで動画生成AIサーバを構築 LTX-Videoでリアルタイム画像生成
動画生成も随分と改良がされています。クローズドでは、PikaやSora、Runwayなど、いくつか高精度な生成が可能なサービスがあります。無料版もありますが、多くは制限があったり、生成時間が長かったりします。一方で、オープンソースの動画生成AIも2024年後半に大きな進歩がありました。CogVideoは有料サービスに迫る精度の動画を生成することができます。また、Mochi 1もよくできています。最新はHunyuanVideoかなと思います。動画生成はモデルも大きく生成に時間がかかるので、ComfyUIで動かしている例がほどんどですね。
Diffusersで動画生成AIを動かす
ComfyUI同様にDiffusersでも、発表されたオープンソースモデルはほとんど動かすことができます。サンプルコードをそのまま実行すれば動くので簡単(??)です。メモリを削減する手法や、生成時間を早くする方法など色々と説明があります。
動画生成サーバが必要
筆者はローカルLLMを主体としてAIキャラクタの個人開発をやっています。AIキャラクタがツールとして実行できる様々な機能を準備して、機能を拡大できるよに考えています。動画生成AIもその一つに加えるべく、今回は色々と調べ、試しました。必要な要件を以下に絞りました。
i2vができること。レファレンスのイメージを元に動画が生成できることです。キャラクタを動かすためには必須だと考えています。
動画のでき具合が良い。当然ですが、高精度の動画生成ができてほしい。
生成時間が短いこと。1秒の動画にどのぐらいの生成時間がかかるかなのですが、早ければ早いほうが良く、いくら精度が高くても生成に数10秒もかかるようだとツールとしては使えません。
LTX-Video
これらの条件に当てはまるモデルが2024年11月に発表されたLTX-Videoです。開発者はリアルタイム動画生成とまで述べているとおり最大の特徴は生成が早いことです。生成時間については最後にいくつか比較を示します。
このモデルには量子化されたgguf版のモデルもあります。VRAMの削減と生成速度の向上が期待されます。ありがたいですね。
動画生成サーバ化
特に難しくは無いのですが、t2vとi2vの2つのpipelineを常時ロードするので通常ならVRAMを大きく消費してしまします。Diffusersのpipelineにはこのようなことを避けるために、pipelineの再利用ができる機能があります。全てのpipelineにこの機能があるわけではないのですが、pipelineにfrom_pipeオプションが使えれば機能します。幸いLTX-VideoのLTXImageToVideoPipelineにはこのオプションがあるので、VRAMの専有が少なめになります。さらに量子化モデルとして,ggufが準備されており、VRAMの容量に合わせて2Bitから8bitまで選ぶことができます。
環境構築
例によって仮想環境を作成して作業をします。
Installationに環境構築からインストールまで丁寧に記述があります。
pip install diffusers["torch"] transformers
pip install -U gguf
pip install -q diffusers transformers accelerate
そのた起動時にエラーがでたら指示にしたがって追加インストルしてください。
サーバ化のための準備
FastAPIでサーバを動かすので以下のインストールが必要です。ffmpegはファイルに書き出しをせずにmp4データを作成するために必要です。できればファイルアクセスは酒帯で受からね。
pip install fastapi
pip inatall uvicorn
pip install ffmpeg-python
pip install opencv-python
サーバーコード
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import Response
import uvicorn
import io
import time
import ffmpeg
import numpy as np
from PIL import Image
import torch
from diffusers.utils import load_image
from diffusers import LTXPipeline, LTXVideoTransformer3DModel, GGUFQuantizationConfig,LTXImageToVideoPipeline
app = FastAPI()
#モデルのロード
ckpt_path = (
#"https://huggingface.co/calcuis/ltxv-gguf/ltx-video-2b-v0.9-q2_k.gguf"
#"https://huggingface.co/city96/LTX-Video-gguf/blob/main/ltx-video-2b-v0.9-Q3_K_S.gguf"
#"https://huggingface.co/city96/LTX-Video-gguf/blob/main/ltx-video-2b-v0.9-Q4_K_M.gguf"
"https://huggingface.co/city96/LTX-Video-gguf/blob/main/ltx-video-2b-v0.9-Q8_0.gguf"
)
transformer = LTXVideoTransformer3DModel.from_single_file(
ckpt_path,
quantization_config=GGUFQuantizationConfig(compute_dtype=torch.bfloat16),
torch_dtype=torch.bfloat16,
)
#T2V
pipe = LTXPipeline.from_pretrained(
"Lightricks/LTX-Video",
transformer=transformer,
torch_dtype=torch.bfloat16,
)
pipe.enable_model_cpu_offload()
#I2V, T2Vのpipelineを再利用
pipe_i2v = LTXImageToVideoPipeline.from_pipe(pipe)
pipe_i2v.enable_model_cpu_offload()
@app.post("/generate_video/")
async def generate_video(
video_mode: str = Form(...),#i2v or t2v
prompt: str = Form(...),
negative_prompt: str = Form(None),
image: UploadFile = File(None), # ❗ オプション化(デフォルト値を `None` に設定)
width: int = Form(576),
height: int =Form(352),
num_frames:int =Form(161),
num_inference_steps:int =Form(50),
guidance_scale:float =Form(3.0),
num_videos_per_prompt: int =Form(1),
seed:int =Form(0),
fps:int =Form(24),
output_type: str =Form("pil"),
):
starttime=time.time()
generator = torch.manual_seed(seed)
# 動画生成
#解像度は32で割り切れるもので、フレーム数は8+1で割り切れる必要があります(例: 257フレーム:256+1)
if video_mode == "i2v":
image_data = await image.read()
image = Image.open(io.BytesIO(image_data)).convert("RGB")
image = load_image(image)
video_frames = pipe_i2v(
prompt=prompt,
image=image,
negative_prompt=negative_prompt,
width=width,
height=height,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
num_videos_per_prompt=num_videos_per_prompt,
generator=generator,
output_type =output_type,
).frames[0]# すべてのフレームを取得
elif video_mode == "t2v":
video_frames = pipe(
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
num_videos_per_prompt=num_videos_per_prompt,
generator=generator,
output_type =output_type,
).frames[0] # すべてのフレームを取得
else:
return {"error": "Invalid video mode"}
generation_time = time.time()-starttime
print(f"generation_time: {generation_time}")
#PIL Image のリスト (video_frames) から MP4 データをメモリ上で生成
width, height = video_frames[0].size # 画像サイズ取得
video_io = io.BytesIO() # メモリ上のバッファ
#FFmpeg プロセスを設定**
process = (
ffmpeg.input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"{width}x{height}", r=fps)
.output(
"pipe:",
format="mp4",
pix_fmt="yuv420p",
vcodec="libx264",
r=fps,
movflags="frag_keyframe+empty_moov", # MP4 ストリーミング対応
)
.run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True)
)
#フレームを FFmpeg に送信**
for frame in video_frames:
frame_np = np.array(frame, dtype=np.uint8) # PIL → NumPy
process.stdin.write(frame_np.tobytes()) # フレームを書き込む
#FFmpeg プロセスの終了処理**
process.stdin.close()
video_io.write(process.stdout.read()) # メモリに MP4 データを保存
process.wait()
video_io.seek(0) # バッファを先頭に戻す
video_bytes =video_io.getvalue() # バイトデータを取得
return Response(
content=video_bytes,
media_type="video/mp4",
headers={"Content-Disposition": "inline; filename=generated_video.mp4"},
)
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=8000)
@app.post("/generate_video/")まではモデルのロードやpipe の初期化などです。
@app.post("/generate_video/")ではvideo_modeでt2vかi2vを指定できます。他のパラメータはほぼ同じですが、i2vのときだけimageが必要です。
クリアント側- t2v
プロンプトはDiffusersのサンプルと同じです。
プロンプトや各種のパラメータをセットして、
response = requests.post(url, data=data, stream=True)
で
@app.post("/generate_video/")
を呼んでいます。
結果はmp4データで帰ってくるおで、保存後に動画として再生しています。何故かOPENCVから警告が出ます(不思議、初めてですね)
生成動画を2倍に拡大は生成画像サイズを小さくして高速化したときに家訓がしやすいようになので、保存されたファイルは生成動画サイズのままです。
import requests
import cv2
import time
url = "http://127.0.0.1:8000/generate_video/"
prompt = "A woman with long brown hair and light skin smiles at another woman with long blonde hair. The woman with brown hair wears a black jacket and has a small, barely noticeable mole on her right cheek. The camera angle is a close-up, focused on the woman with brown hair's face. The lighting is warm and natural, likely from the setting sun, casting a soft glow on the scene. The scene appears to be real-life footage"
#prompt = "The camera pans across a cityscape of tall buildings with a circular building in the center. The camera moves from left to right, showing the tops of the buildings and the circular building in the center. The buildings are various shades of gray and white, and the circular building has a green roof. The camera angle is high, looking down at the city. The lighting is bright, with the sun shining from the upper left, casting shadows from the buildings. The scene is computer-generated imagery."
negative_prompt = "worst quality, inconsistent motion, blurry, jittery, distorted"
fps = 15 # 生成された動画のフレームレート
data = {
"video_mode": "t2v",
"prompt": prompt,
"negative_prompt": negative_prompt,
"width": 512, #32で割り切れる
"height": 320, #8で割り切れる+1 (ex, 256+1)
"num_inference_steps": 50,
"num_frames": 257,
"fps": fps,
"seed": 8880,
}
start_time = time.time()
# クエリパラメータを追加してPOSTリクエストを送信
response = requests.post(url, data=data, stream=True)
if response.status_code == 200:
video_path = "received_video.mp4"
with open(video_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"動画が {video_path} に保存されました。")
recived_time = time.time() - start_time
print(f"recived_time: {recived_time}")
# OpenCVで動画を再生**
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print("動画ファイルを開けませんでした。")
exit()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
#拡大後のサイズ**
new_width = width * 2
new_height = height * 2
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break # 動画が終了したらループを抜ける
try:
# **画像を 2 倍に拡大**
frame_resized = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
cv2.imshow("Generated Video", frame_resized)
except Exception as e:
print(f"cv2.imshow() エラー: {e}")
#動画のフレームレートに応じた waitKey()**
wait_time = int(1000 / fps) # ミリ秒単位
if cv2.waitKey(wait_time) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
クリアント側- i2v
レファレンスの画層がimage_path= "momo.jpg"担っているので、コードの次にある画像をダウンロードして利用ください。プロンプトはこの画像用に最適化しています。
import requests
import cv2
import time
# サーバーのURLとエンドポイント
url = "http://127.0.0.1:8000/generate_video/"
tive_prompt= "worst quality, inconsistent motion, blurry, jittery, distorted, big movements, flickering, low resolution, low frame rate, low quality, bad lighting, bad contrast, bad colors, bad focus, bad composition, bad framing, bad camera work, bad acting, bad pacing, bad direction, bad special effects"
prompt = "A fluffy white stuffed animal resembling a dog sits at a wooden desk, holding a black pen in its paw, carefully writing in a vintage diary with yellowed pages. Its soft, round ears occasionally twitch subtly, adding lifelike charm to its movements. After writing a few lines, it gently flips the page, the sound of the paper turning blending with the soft scratching of the pen. The stuffed animal wears a festive red Santa hat slightly tilted to one side, contrasting beautifully with its snowy white fur. The desk is neatly arranged with books stacked on the side, adding to the cozy ambiance. Behind it, a warmly lit library with tall shelves of colorful books and a large window showcases soft natural light streaming in, casting golden rays across the desk and highlighting the plush textures of the fur. The camera is set at a medium close-up angle, focusing on the stuffed animal's upper body, its paw delicately holding the pen as it writes. The warm, golden lighting enhances the serene and inviting environment, while the occasional twitch of its ears suggests it is quietly attentive to its surroundings. The scene is calm and whimsical, evoking a sense of innocence and creativity."
negative_prompt= "worst quality, inconsistent motion, blurry, jittery, distorted, flickering, low resolution, low frame rate, low quality, bad lighting, bad contrast, bad colors, bad focus, bad composition, bad framing, bad camera work, bad acting, bad pacing, bad direction, bad special effects"
image_path= "momo.jpg"
fps = 15 # 生成された動画のフレームレート
start_time = time.time()
# クエリパラメータ(URLに付加)
params = {
"video_mode": "i2v",
"prompt": prompt,
"negative_prompt": negative_prompt,
"width": 480, #32で割り切れる
"height": 320, #8で割り切れる+1 (ex, 256+1)
"num_inference_steps": 30,
"num_frames": 257,
"fps": fps,
"seed": 0,
}
#files = {"image": open("momo.jpg", "rb")} # 入力画像ファイルのパス
files = {"image": open(image_path, "rb")} # 入力画像ファイルのパス
# クエリパラメータを追加してPOSTリクエストを送信
response = requests.post(url, data=params, files=files, stream=True)
if response.status_code == 200:
video_path = "received_video.mp4"
with open(video_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"動画が {video_path} に保存されました。")
recived_time = time.time() - start_time
print(f"recived_time: {recived_time}")
# OpenCVで動画を再生**
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print("動画ファイルを開けませんでした。")
exit()
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break # 動画が終了したらループを抜ける
try:
cv2.imshow("Generated Video", frame)
except Exception as e:
print(f"cv2.imshow() エラー: {e}")
# 動画のフレームレートに応じた waitKey()**
wait_time = int(1000 / fps) # ミリ秒単位
if cv2.waitKey(wait_time) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
以下の画像をダウンロードし、momo.jpgで保存してください
生成結果
使用したGPUは全て4090-24gです。4060ti-16Gでも動くことを確認しています。生成時間が4倍程度長くなります。
コンディション1
"width": 480
"height": 320
"num_inference_steps": 30
num_frames": 257
画像再生時間 16秒
生成時間 24秒
生成時間の方が少し長いのでリアルタイムとは言えないですね。画像サイズをもう少しだけ小さくするか、num_inference_steps": 30を小さくすれば生成時間が短くなるはずですが、num_inference_steps": 30は30以下だと破綻が増えるので、あまり好ましくはないと思います。
コンディション2
"width": 256,
"height": 160,
"num_inference_steps": 30
num_frames": 161
再生時間は同じ10.7秒
生成時間=8.76秒
これで再生時間よりも生成時間が短くなりました。しかし、256x160はいかにも小さいです。
コンディション3
"width": 448, #32で割り切れる
"height": 288, #8で割り切れる+1 (ex, 256+1)
"num_inference_steps": 22,
"num_frames": 257,
画像再生時間 16秒
生成時間 16,7秒
ギリギリでリアルタイム生成になります。"num_inference_steps": 22,は標準の半分なので画像精度に問題が出いる可能性がありますが、生成された画層はそれなりに悪くはないです。
プロンプト作成指針
Prompt Engineering
When writing prompts, focus on detailed, chronological descriptions of actions and scenes. Include specific movements, appearances, camera angles, and environmental details - all in a single flowing paragraph. Start directly with the action, and keep descriptions literal and precise. Think like a cinematographer describing a shot list. Keep within 200 words. For best results, build your prompts using this structure:
Start with main action in a single sentence
Add specific details about movements and gestures
Describe character/object appearances precisely
Include background and environment details
Specify camera angles and movements
Describe lighting and colors
Note any changes or sudden events
See examples for more inspiration.
まとめ
これで、動画生が成無限に利用できるようになりました。1秒あたり1秒で生成できれば、キャラクタを動かすことも夢ではなくなりますね。