Spaces:
Sleeping
Sleeping
Create video_export.py
Browse files- src/video_export.py +167 -0
src/video_export.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/video_export.py
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 8 |
+
from moviepy.editor import ImageClip, concatenate_videoclips
|
| 9 |
+
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
| 10 |
+
from gtts import gTTS
|
| 11 |
+
|
| 12 |
+
from src.utils import APP_DIR
|
| 13 |
+
|
| 14 |
+
def ensure_video_deps():
|
| 15 |
+
# ensure font exists; DejaVu Sans is usually present via fonts-dejavu-core
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
def _get_font(size: int):
|
| 19 |
+
for p in [
|
| 20 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 21 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf",
|
| 22 |
+
]:
|
| 23 |
+
if os.path.exists(p):
|
| 24 |
+
return ImageFont.truetype(p, size=size)
|
| 25 |
+
return ImageFont.load_default()
|
| 26 |
+
|
| 27 |
+
def _draw_multiline(draw, text, xy, font, fill, max_w, leading=6):
|
| 28 |
+
text = (text or "").strip()
|
| 29 |
+
if not text:
|
| 30 |
+
text = "."
|
| 31 |
+
words = text.split()
|
| 32 |
+
lines, cur = [], []
|
| 33 |
+
for w in words:
|
| 34 |
+
test = " ".join(cur + [w]).strip()
|
| 35 |
+
w_px = draw.textbbox((0,0), test, font=font)[2]
|
| 36 |
+
if w_px <= max_w: cur.append(w)
|
| 37 |
+
else:
|
| 38 |
+
lines.append(" ".join(cur).strip()); cur = [w]
|
| 39 |
+
if cur: lines.append(" ".join(cur).strip())
|
| 40 |
+
x, y = xy
|
| 41 |
+
for line in lines:
|
| 42 |
+
draw.text((x,y), line, font=font, fill=fill)
|
| 43 |
+
_,_,_,h = draw.textbbox((x,y), line, font=font)
|
| 44 |
+
y += h + leading
|
| 45 |
+
return y
|
| 46 |
+
|
| 47 |
+
def build_slide(title, body, w=1280, h=720, seconds=3.5, bg=(245,247,250)):
|
| 48 |
+
img = Image.new("RGB", (w,h), color=bg)
|
| 49 |
+
d = ImageDraw.Draw(img)
|
| 50 |
+
title_font = _get_font(48)
|
| 51 |
+
body_font = _get_font(36)
|
| 52 |
+
left, right = 60, w-60
|
| 53 |
+
max_w = right - left
|
| 54 |
+
y0 = 80
|
| 55 |
+
y1 = _draw_multiline(d, title, (left,y0), title_font, "black", max_w, 8)
|
| 56 |
+
y_body = max(y0 + 80, y1 + 20)
|
| 57 |
+
_draw_multiline(d, body, (left,y_body), body_font, "black", max_w, 6)
|
| 58 |
+
frame = np.array(img)
|
| 59 |
+
return ImageClip(frame).set_duration(seconds)
|
| 60 |
+
|
| 61 |
+
def tts(text: str, lang: str, out_mp3: str):
|
| 62 |
+
safe = (text or ".").strip()
|
| 63 |
+
gTTS(text=safe, lang=lang, slow=False).save(out_mp3)
|
| 64 |
+
return out_mp3
|
| 65 |
+
|
| 66 |
+
def narration_text(title: str, body: str, max_chars: int = 600) -> str:
|
| 67 |
+
txt = f"{title}. {body}".strip()
|
| 68 |
+
return (txt[:max_chars-1].rsplit(" ", 1)[0] + "…") if len(txt) > max_chars else txt
|
| 69 |
+
|
| 70 |
+
def make_video(rows, title="Olist Chatbot — Conversation", narrate=True, tts_lang="en",
|
| 71 |
+
seconds_per_slide=3.5, out_path="chat_video.mp4"):
|
| 72 |
+
tmp_dir = APP_DIR / ".cache" / "tts_tmp"
|
| 73 |
+
if narrate:
|
| 74 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 75 |
+
|
| 76 |
+
clips = []
|
| 77 |
+
def _add(title, body, bg=None):
|
| 78 |
+
duration = seconds_per_slide
|
| 79 |
+
audio = None
|
| 80 |
+
if narrate:
|
| 81 |
+
try:
|
| 82 |
+
mp3 = str(tmp_dir / f"tts_{len(clips):04d}.mp3")
|
| 83 |
+
tts(narration_text(title, body), tts_lang, mp3)
|
| 84 |
+
audio = AudioFileClip(mp3)
|
| 85 |
+
duration = max(seconds_per_slide, float(audio.duration) + 0.4)
|
| 86 |
+
except Exception:
|
| 87 |
+
audio = None
|
| 88 |
+
clip = build_slide(title, body, seconds=duration, bg=(245,247,250) if bg is None else bg)
|
| 89 |
+
if narrate and audio is not None:
|
| 90 |
+
clip = clip.set_audio(audio)
|
| 91 |
+
clips.append(clip)
|
| 92 |
+
|
| 93 |
+
_add(title, "Auto-generated summary video")
|
| 94 |
+
for _, r in rows.iterrows():
|
| 95 |
+
ts = str(r.get("timestamp",""))
|
| 96 |
+
msg = str(r.get("user_message",""))
|
| 97 |
+
rep = str(r.get("bot_reply",""))
|
| 98 |
+
_add(f"User • {ts}", msg, bg=(255,255,255))
|
| 99 |
+
_add("Agent", rep, bg=(242,255,242))
|
| 100 |
+
_add("Thanks for watching", "Exported from the Olist app")
|
| 101 |
+
|
| 102 |
+
video = concatenate_videoclips(clips, method="compose")
|
| 103 |
+
video.write_videofile(
|
| 104 |
+
out_path, fps=24, codec="libx264",
|
| 105 |
+
audio=narrate, audio_codec="aac" if narrate else None,
|
| 106 |
+
threads=2, preset="medium", ffmpeg_params=["-movflags","+faststart"]
|
| 107 |
+
)
|
| 108 |
+
# cleanup tts mp3s best effort
|
| 109 |
+
try:
|
| 110 |
+
if narrate and os.path.isdir(tmp_dir):
|
| 111 |
+
for f in os.listdir(tmp_dir):
|
| 112 |
+
if f.endswith(".mp3"):
|
| 113 |
+
os.remove(tmp_dir / f)
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
return out_path
|
| 117 |
+
|
| 118 |
+
def render_admin_export_video_tab(conn):
|
| 119 |
+
st.title("📥 Export + 🎬 Video (Admin)")
|
| 120 |
+
st.write("Download CSV/TXT for all users, or render a narrated MP4.")
|
| 121 |
+
|
| 122 |
+
df = pd.read_sql_query(
|
| 123 |
+
"SELECT user_id, timestamp, user_message, bot_reply FROM chat_history ORDER BY timestamp DESC",
|
| 124 |
+
conn
|
| 125 |
+
)
|
| 126 |
+
if df.empty:
|
| 127 |
+
st.info("No chats yet.")
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
# CSV/TXT export for admins (all users)
|
| 131 |
+
csv_bytes = df.to_csv(index=False).encode("utf-8")
|
| 132 |
+
st.download_button("Download all chats (CSV)", csv_bytes, file_name="all_chats.csv", mime="text/csv")
|
| 133 |
+
|
| 134 |
+
txt_lines = []
|
| 135 |
+
for _, r in df.iterrows():
|
| 136 |
+
txt_lines.append(f"[{r['timestamp']}] user_id={r['user_id']} | {r['user_message']}")
|
| 137 |
+
txt_lines.append(f"Bot: {r['bot_reply']}")
|
| 138 |
+
txt_lines.append("---")
|
| 139 |
+
st.download_button("Download all chats (TXT)", "\n".join(txt_lines).encode("utf-8"),
|
| 140 |
+
file_name="all_chats.txt", mime="text/plain")
|
| 141 |
+
|
| 142 |
+
st.markdown("---")
|
| 143 |
+
st.subheader("🎬 Build video")
|
| 144 |
+
c1, c2 = st.columns(2)
|
| 145 |
+
with c1:
|
| 146 |
+
narrate = st.checkbox("Add voiceover (gTTS)", value=True)
|
| 147 |
+
seconds_per_slide = st.slider("Seconds per slide", 2.0, 8.0, 3.5, 0.5)
|
| 148 |
+
with c2:
|
| 149 |
+
tts_lang = st.selectbox("TTS language", ["en","hi","pt","es","fr"], index=0)
|
| 150 |
+
max_rows = st.number_input("Max messages (0 = all)", min_value=0, max_value=200, value=50, step=1)
|
| 151 |
+
|
| 152 |
+
rows = df if max_rows == 0 else df.head(int(max_rows))
|
| 153 |
+
|
| 154 |
+
if st.button("Make Video"):
|
| 155 |
+
try:
|
| 156 |
+
out_path = make_video(rows, narrate=narrate, tts_lang=tts_lang,
|
| 157 |
+
seconds_per_slide=seconds_per_slide, out_path="chat_video.mp4")
|
| 158 |
+
with open(out_path, "rb") as f:
|
| 159 |
+
data = f.read()
|
| 160 |
+
st.success("Video ready!")
|
| 161 |
+
st.video(data)
|
| 162 |
+
st.download_button(
|
| 163 |
+
"Download MP4", data, file_name=f"chat_video_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4",
|
| 164 |
+
mime="video/mp4"
|
| 165 |
+
)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
st.error(f"Video generation failed: {e}")
|