diff --git a/README.md b/README.md index 712d0f8..6c4e488 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif new file mode 100644 index 0000000..70c0d6b Binary files /dev/null and b/docs/assets/demo.gif differ diff --git a/scripts/_demo_audio.py b/scripts/_demo_audio.py new file mode 100644 index 0000000..79b205b --- /dev/null +++ b/scripts/_demo_audio.py @@ -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) diff --git a/scripts/build_demo_media.py b/scripts/build_demo_media.py index cddcdd9..e4c0a50 100644 --- a/scripts/build_demo_media.py +++ b/scripts/build_demo_media.py @@ -15,16 +15,23 @@ """ 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): @@ -32,6 +39,61 @@ def _round_to_even(value): 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(): @@ -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)) @@ -61,6 +124,7 @@ 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) @@ -68,7 +132,8 @@ def build_mp4(frame_paths, out_path, seconds_per_frame, fps): 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 @@ -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) @@ -102,6 +168,8 @@ 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) @@ -109,8 +177,24 @@ def build_demo_media( 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 @@ -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: @@ -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) diff --git a/scripts/demo_captions.json b/scripts/demo_captions.json new file mode 100644 index 0000000..cc66252 --- /dev/null +++ b/scripts/demo_captions.json @@ -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" +} diff --git a/tests/test_build_demo_media.py b/tests/test_build_demo_media.py index bcaaa0e..9423176 100644 --- a/tests/test_build_demo_media.py +++ b/tests/test_build_demo_media.py @@ -5,7 +5,9 @@ session. See scripts/build_demo_media.py and _docs/DEMO-script.md. """ +import subprocess import sys +import wave from pathlib import Path import pytest @@ -13,11 +15,26 @@ pytest.importorskip("PIL", reason="demo tooling dep (Pillow) not installed") pytest.importorskip("imageio_ffmpeg", reason="demo tooling dep not installed") +import imageio_ffmpeg from PIL import Image sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) -from build_demo_media import build_demo_media, load_frame_paths # noqa: E402 +import build_demo_media as bdm # noqa: E402 +from _demo_audio import synth_music # noqa: E402 +from build_demo_media import ( # noqa: E402 + apply_caption, + build_demo_media, + load_frame_paths, +) + + +def _probe_has_audio(path): + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + result = subprocess.run( + [ffmpeg_exe, "-i", str(path)], capture_output=True, text=True + ) + return "Audio:" in result.stderr def _make_frames(tmp_path, count=3, size=(1280, 720)): @@ -123,3 +140,94 @@ def test_load_frame_paths_empty_dir_raises(tmp_path): empty_dir.mkdir() with pytest.raises(FileNotFoundError): load_frame_paths(empty_dir) + + +# --- Captions ----------------------------------------------------------- + + +def test_apply_caption_changes_bottom_band_pixels(): + img = Image.new("RGB", (400, 300), (10, 20, 30)) + captioned = apply_caption(img, "Hello caption") + + assert captioned.size == img.size + assert captioned.getpixel((200, 290)) != img.getpixel((200, 290)) + + +def test_apply_caption_no_caption_is_noop(): + img = Image.new("RGB", (400, 300), (10, 20, 30)) + out = apply_caption(img, None) + assert list(out.getdata()) == list(img.getdata()) + + +def test_load_font_falls_back_to_default_when_no_font_files_found(monkeypatch): + monkeypatch.setattr(bdm, "_CAPTION_FONT_PATHS", ("Z:/does-not-exist.ttf",)) + font = bdm._load_font(20) + assert font is not None + + +def test_build_demo_media_with_captions_overlays_bar_on_matching_frame(tmp_path): + frames_dir = _make_frames(tmp_path, count=3) + captions = {"00-frame.png": "Caption One"} + out_dir = tmp_path / "out" + + mp4_path, gif_path = build_demo_media( + frames_dir, + out_dir, + seconds_per_frame=0.2, + fps=10, + gif_width=400, + captions=captions, + ) + + assert mp4_path.exists() + with Image.open(gif_path) as gif: + assert gif.n_frames == 3 + gif.seek(0) + captioned_frame = gif.convert("RGB") + bottom_pixel = captioned_frame.getpixel( + (captioned_frame.width // 2, captioned_frame.height - 5) + ) + assert bottom_pixel != (200, 0, 0) # darkened by the caption bar + + gif.seek(1) + uncaptioned_frame = gif.convert("RGB") + bottom_pixel_2 = uncaptioned_frame.getpixel( + (uncaptioned_frame.width // 2, uncaptioned_frame.height - 5) + ) + assert bottom_pixel_2 == (0, 200, 0) # untouched, no caption for this frame + + +# --- Music / audio mux --------------------------------------------------- + + +def test_synth_music_writes_valid_wav(tmp_path): + out_path = tmp_path / "music.wav" + synth_music(1.0, out_path) + + assert out_path.exists() + with wave.open(str(out_path), "rb") as w: + assert w.getframerate() == 44100 + assert w.getsampwidth() == 2 + assert w.getnframes() > 0 + + +def test_build_demo_media_with_music_has_audio_stream(tmp_path): + frames_dir = _make_frames(tmp_path, count=3) + out_dir = tmp_path / "out" + + mp4_path, _gif_path = build_demo_media( + frames_dir, out_dir, seconds_per_frame=0.2, fps=10, gif_width=400, music=True + ) + + assert _probe_has_audio(mp4_path) + + +def test_build_demo_media_without_music_has_no_audio_stream(tmp_path): + frames_dir = _make_frames(tmp_path, count=3) + out_dir = tmp_path / "out" + + mp4_path, _gif_path = build_demo_media( + frames_dir, out_dir, seconds_per_frame=0.2, fps=10, gif_width=400, music=False + ) + + assert not _probe_has_audio(mp4_path)