Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
[![API](https://img.shields.io/badge/API-64%20endpoints-blue)]()
[![Version](https://img.shields.io/badge/version-1.0.0-orange)]()

![ElevareAI demo](docs/assets/demo.gif)
*A quick tour: sign in, dashboard, AI Q&A with math rendering, adaptive practice, goals, and progress.*

---

## ✨ What You Can Do with ElevareAI
Expand Down
Binary file added docs/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
115 changes: 115 additions & 0 deletions scripts/_demo_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Synthesize a gentle background-music WAV track and mux it into an mp4.

Pure stdlib (wave + math + array) — no numpy, no soundfont. The "instrument"
is a plain sine wave with a short attack/decay envelope per note, arpeggiated
over a calm four-chord major progression (C - G - Am - F). Used by
scripts/build_demo_media.py to add a low-volume, non-annoying music bed to
the generated demo.mp4 (the .gif stays silent).
"""

import array
import math
import subprocess
import wave
from pathlib import Path

import imageio_ffmpeg

SAMPLE_RATE = 44100
DEFAULT_AMPLITUDE = 0.15 # keep quiet: headroom under int16 full-scale, no clipping
DEFAULT_NOTE_DURATION = 0.45 # seconds per arpeggio note

# MIDI note numbers per chord, arpeggiated low -> high.
# C major (C3 E3 G3 C4) - G major (G3 B3 D4 G4) - A minor (A3 C4 E4 A4) - F major (F3 A3 C4 F4)
_PROGRESSION_MIDI = [
(48, 52, 55, 60),
(55, 59, 62, 67),
(57, 60, 64, 69),
(53, 57, 60, 65),
]


def _midi_to_freq(note):
return 440.0 * (2.0 ** ((note - 69) / 12.0))


def _note_samples(freq, num_samples, amplitude):
"""One sine-wave note with a short linear attack/decay envelope (click-free)."""
ramp = max(1, min(num_samples // 6, int(SAMPLE_RATE * 0.03)))
out = [0.0] * num_samples
for i in range(num_samples):
value = math.sin(2 * math.pi * freq * (i / SAMPLE_RATE))
if i < ramp:
value *= i / ramp
elif i >= num_samples - ramp:
value *= (num_samples - i) / ramp
out[i] = value * amplitude
return out


def synth_music(
duration_seconds,
out_path,
note_duration=DEFAULT_NOTE_DURATION,
amplitude=DEFAULT_AMPLITUDE,
):
"""Write a calm, arpeggiated WAV track (mono, 16-bit, 44.1kHz) to out_path."""
out_path = Path(out_path)
total_samples = max(1, int(duration_seconds * SAMPLE_RATE))
note_samples_count = max(1, int(note_duration * SAMPLE_RATE))

track = []
chord_idx = 0
while len(track) < total_samples:
chord = _PROGRESSION_MIDI[chord_idx % len(_PROGRESSION_MIDI)]
for note in chord:
track.extend(
_note_samples(_midi_to_freq(note), note_samples_count, amplitude)
)
if len(track) >= total_samples:
break
chord_idx += 1
track = track[:total_samples]

# Short overall fade-in/out so the track doesn't start or stop abruptly.
fade_samples = min(len(track) // 4, int(SAMPLE_RATE * 1.0))
for i in range(fade_samples):
gain = i / fade_samples
track[i] *= gain
track[-(i + 1)] *= gain

pcm = array.array("h", (int(max(-1.0, min(1.0, v)) * 32767) for v in track))

with wave.open(str(out_path), "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(SAMPLE_RATE)
wav_file.writeframes(pcm.tobytes())

return out_path


def mux_audio(video_path, audio_path, out_path, ffmpeg_exe=None):
"""Mux audio_path into video_path (video re-encode-free) and write out_path."""
ffmpeg_exe = ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe()
cmd = [
ffmpeg_exe,
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-c:v",
"copy",
"-c:a",
"aac",
"-shortest",
str(out_path),
]
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
raise RuntimeError(
f"ffmpeg audio mux failed (exit {result.returncode}): "
f"{result.stderr.decode(errors='replace')}"
)
return Path(out_path)
106 changes: 101 additions & 5 deletions scripts/build_demo_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,85 @@
"""

import argparse
import json
import shutil
import sys
import tempfile
from pathlib import Path

import imageio_ffmpeg
from PIL import Image
from _demo_audio import mux_audio, synth_music
from PIL import Image, ImageDraw, ImageFont

DEFAULT_SECONDS_PER_FRAME = 2.5
DEFAULT_FPS = 30
DEFAULT_GIF_WIDTH = 800
DEFAULT_OUT_DIR = "demo-media"
DEFAULT_CAPTIONS_PATH = Path(__file__).parent / "demo_captions.json"

_CAPTION_FONT_PATHS = ("C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf")


def _round_to_even(value):
"""ffmpeg's yuv420p output needs even width/height."""
return value if value % 2 == 0 else value - 1


def load_captions(captions_path):
"""Load a {frame filename: caption text} mapping. Missing file -> no captions."""
if not captions_path:
return {}
captions_path = Path(captions_path)
if not captions_path.exists():
return {}
with open(captions_path, "r", encoding="utf-8") as f:
return json.load(f)


def _load_font(size):
for font_path in _CAPTION_FONT_PATHS:
try:
return ImageFont.truetype(font_path, size)
except OSError:
continue
return ImageFont.load_default()


def _fit_font(draw, text, max_width, start_size, min_size=12):
size = max(start_size, min_size)
font = _load_font(size)
while size > min_size:
bbox = draw.textbbox((0, 0), text, font=font)
if bbox[2] - bbox[0] <= max_width:
break
size -= 2
font = _load_font(size)
return font


def apply_caption(img, caption):
"""Draw a semi-transparent caption bar at the bottom of img (RGB -> RGB)."""
if not caption:
return img
rgba = img.convert("RGBA")
width, height = rgba.size
bar_height = max(1, round(height * 0.12))

overlay = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
draw.rectangle([(0, height - bar_height), (width, height)], fill=(0, 0, 0, 170))

font = _fit_font(draw, caption, width * 0.9, start_size=max(14, bar_height // 2))
bbox = draw.textbbox((0, 0), caption, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
text_x = (width - text_w) / 2 - bbox[0]
text_y = height - bar_height + (bar_height - text_h) / 2 - bbox[1]
draw.text((text_x, text_y), caption, font=font, fill=(255, 255, 255, 255))

return Image.alpha_composite(rgba, overlay).convert("RGB")


def load_frame_paths(frames_dir):
frames_dir = Path(frames_dir)
if not frames_dir.exists():
Expand All @@ -42,7 +104,8 @@ def load_frame_paths(frames_dir):
return paths


def build_mp4(frame_paths, out_path, seconds_per_frame, fps):
def build_mp4(frame_paths, out_path, seconds_per_frame, fps, captions=None):
captions = captions or {}
first = Image.open(frame_paths[0]).convert("RGB")
width = max(2, _round_to_even(first.width))
height = max(2, _round_to_even(first.height))
Expand All @@ -61,14 +124,16 @@ def build_mp4(frame_paths, out_path, seconds_per_frame, fps):
img = Image.open(path).convert("RGB")
if img.size != (width, height):
img = img.resize((width, height))
img = apply_caption(img, captions.get(path.name))
frame_bytes = img.tobytes()
for _ in range(hold_frames):
writer.send(frame_bytes)
finally:
writer.close()


def build_gif(frame_paths, out_path, seconds_per_frame, gif_width):
def build_gif(frame_paths, out_path, seconds_per_frame, gif_width, captions=None):
captions = captions or {}
first = Image.open(frame_paths[0]).convert("RGB")
canvas_width = first.width
canvas_height = first.height
Expand All @@ -83,6 +148,7 @@ def build_gif(frame_paths, out_path, seconds_per_frame, gif_width):
if gif_width and img.width > gif_width:
ratio = gif_width / img.width
img = img.resize((gif_width, max(1, round(img.height * ratio))))
img = apply_caption(img, captions.get(path.name))
frames.append(img)

duration_ms = int(seconds_per_frame * 1000)
Expand All @@ -102,15 +168,33 @@ def build_demo_media(
seconds_per_frame=DEFAULT_SECONDS_PER_FRAME,
fps=DEFAULT_FPS,
gif_width=DEFAULT_GIF_WIDTH,
captions=None,
music=False,
):
frame_paths = load_frame_paths(frames_dir)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)

mp4_path = out_dir / "demo.mp4"
gif_path = out_dir / "demo.gif"
build_mp4(frame_paths, mp4_path, seconds_per_frame, fps)
build_gif(frame_paths, gif_path, seconds_per_frame, gif_width)
build_mp4(frame_paths, mp4_path, seconds_per_frame, fps, captions)
build_gif(frame_paths, gif_path, seconds_per_frame, gif_width, captions)

if music:
total_duration = seconds_per_frame * len(frame_paths)
with tempfile.TemporaryDirectory() as tmp_dir:
audio_path = Path(tmp_dir) / "music.wav"
synth_music(total_duration, audio_path)
muxed_path = Path(tmp_dir) / "demo_with_music.mp4"
try:
mux_audio(mp4_path, audio_path, muxed_path)
shutil.move(str(muxed_path), str(mp4_path))
except Exception as exc: # noqa: BLE001 - never lose the silent mp4
print(
f"Warning: audio mux failed ({exc}); keeping silent mp4.",
file=sys.stderr,
)

return mp4_path, gif_path


Expand Down Expand Up @@ -144,6 +228,16 @@ def main():
default=DEFAULT_GIF_WIDTH,
help=f"GIF output width in pixels, downscaled proportionally (default: {DEFAULT_GIF_WIDTH})",
)
parser.add_argument(
"--captions",
default=str(DEFAULT_CAPTIONS_PATH),
help=f"JSON file mapping frame filename -> caption text (default: {DEFAULT_CAPTIONS_PATH})",
)
parser.add_argument(
"--music",
action="store_true",
help="Mux gentle synthesized background music into demo.mp4 (gif stays silent)",
)
args = parser.parse_args()

try:
Expand All @@ -153,6 +247,8 @@ def main():
args.seconds_per_frame,
args.fps,
args.gif_width,
captions=load_captions(args.captions),
music=args.music,
)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
Expand Down
8 changes: 8 additions & 0 deletions scripts/demo_captions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"01-login.png": "Sign in — self-hosted JWT auth",
"02-dashboard.png": "Dashboard — gentle nudges & progress at a glance",
"03-qa-math.png": "AI Q&A — real math rendering (KaTeX) + confidence",
"05-practice.png": "Adaptive practice — AI-generated questions by subject",
"07-goals.png": "Goals — Elo-rated mastery tracking",
"08-progress.png": "Progress — completion, streaks & smart suggestions"
}
Loading
Loading