diff --git a/.gitignore b/.gitignore index 66e6755..dab821e 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,7 @@ scripts/setup_goal_complete_demo.py # Local-only docs (not committed) _docs/local/ + +# Demo media build output (screenshots -> mp4/gif) - see scripts/build_demo_media.py +demo-media/ +_docs/local/demo-frames/ diff --git a/_docs/DEMO-script.md b/_docs/DEMO-script.md new file mode 100644 index 0000000..5478374 --- /dev/null +++ b/_docs/DEMO-script.md @@ -0,0 +1,88 @@ +# Demo Walkthrough Script (#48) + +A narratable, screenshot-by-screenshot demo of the live app. Matches the +actual routes in `examples/frontend-starter/src/App.jsx` and the actual +page components — no invented features. + +- **Frontend**: https://elevareai-frontend.onrender.com +- **API**: https://elevareai-api.onrender.com +- **Demo account**: `demo@elevare.ai` — password is the `DEMO_PASSWORD` + value (never write the literal password anywhere; see README "Set Manual + Secrets" / Render dashboard). + +## Pre-demo warm-up (do this first, every time) + +Render free-tier web services spin down after ~15 min idle; the first +request after idle takes ~50s. Wake the API before the audience is watching: + +```bash +curl https://elevareai-api.onrender.com/health +# wait for {"status":"healthy","database":"connected"} - may take ~50s +curl https://elevareai-api.onrender.com/health +# second call should return near-instantly once warm +``` + +Then load the frontend once yourself (not screenshotted) so its own cold +start (static site, usually fast) is also out of the way before recording. + +## Demo beats + +Each beat = one screenshot. Capture in order; filenames are consumed +in sorted order by `scripts/build_demo_media.py`, so the numeric prefix +controls playback order. + +| # | Filename | Route | Action | What to show | Narration line | +|---|----------|-------|--------|---------------|-----------------| +| 1 | `01-login.png` | `/login` | Land on the login page | ElevareAI logo, tagline "Lift your learning, gently.", email/password form | "This is ElevareAI - an AI study companion that lives between tutoring sessions." | +| 2 | `02-dashboard.png` | `/dashboard` | Log in as `demo@elevare.ai` | The goals pie chart, nudges (if any) | "After logging in, the student lands on their dashboard - goals and progress at a glance." | +| 3 | `03-qa-math.png` | `/qa` | Ask a math question, e.g. "How do I solve x squared plus 5x plus 6 equals 0?" | The rendered answer with KaTeX-formatted math (equations, not raw LaTeX text) and the Confidence badge | "Students can ask questions any time - answers render real math notation, not plain text, and each answer carries a confidence rating." | +| 4 | `04-qa-history.png` | `/qa` (reload or revisit) | Show the conversation history loading in | Prior Q&A pairs above the live one, "Showing conversation history" banner | "The AI remembers previous questions - this is persistent memory, not a one-off chatbot." | +| 5 | `05-practice.png` | `/practice` | Pick a subject (from the student's goals) and generate a practice set | The AI-generated question list for that subject | "Practice questions are generated per-subject from the student's active goals." | +| 6 | `06-practice-answer.png` | `/practice` | Answer a practice question | The "Correct!" / feedback state with explanation | "Immediate feedback with an explanation - not just right/wrong." | +| 7 | `07-goals.png` | `/goals` | View goals list | Active/completed goals, subjects, target dates | "Goals drive everything - practice subjects and suggestions all come from here." | +| 8 | `08-progress.png` | `/progress` | View progress page | Elo ratings per goal, completion dates, related-subject suggestions | "Progress tracks skill level (Elo) per subject and suggests what to learn next - this is what keeps students engaged after they finish a goal." | + +Eight beats is the target; drop 4 (history) or 6 (practice-answer) if +time is short, but keep 1, 2, 3, 5, 7, 8 as the minimum spine (login, +dashboard, QA math, practice, goals, progress). + +## Capture checklist + +See `scripts/demo_capture_checklist.md` for the full step-by-step capture +process (manual screenshot vs. chrome-devtools MCP automation) and the +frame naming convention. + +## Building the video/gif from frames + +1. Save the 8 (or however many) PNGs above into a local, **gitignored** + frames directory, e.g. `_docs/local/demo-frames/`, using exactly the + filenames from the table so sort order matches the beat order. +2. Install the two build dependencies (not in `requirements.txt` - + they're only needed for this one-off media build): + ```bash + pip install imageio-ffmpeg Pillow + ``` +3. Run the build script: + ```bash + python scripts/build_demo_media.py \ + --frames-dir _docs/local/demo-frames \ + --out-dir demo-media \ + --seconds-per-frame 2.5 \ + --fps 30 \ + --gif-width 800 + ``` +4. Output lands in `demo-media/demo.mp4` and `demo-media/demo.gif`. Both + `demo-media/` and `_docs/local/` are gitignored - **do not commit the + frames or the rendered media.** + +## What's owner-dependent (not done here) + +- **Real screenshots**: not captured in this change - the browser profile + used by this session is locked, so only synthetic (solid-color) + placeholder frames were used to prove the build pipeline works. The + owner needs to capture the real 8 frames per the checklist. +- **Whether to commit final media**: this script and this doc keep + `demo.mp4`/`demo.gif` out of git entirely (gitignored `demo-media/` + output dir). If the finished demo video should ship in the repo or as a + GitHub Release asset, that's an owner decision to make later - attach + it to a release rather than committing a binary to `main`. diff --git a/scripts/build_demo_media.py b/scripts/build_demo_media.py new file mode 100644 index 0000000..cddcdd9 --- /dev/null +++ b/scripts/build_demo_media.py @@ -0,0 +1,166 @@ +"""Build demo.mp4 and demo.gif from an ordered directory of PNG screenshot frames. + +Reads every ``*.png`` file in --frames-dir (sorted by filename, so name +frames ``01-login.png``, ``02-qa-math.png``, ... to control order), holds +each frame on screen for --seconds-per-frame, and writes: + + - /demo.mp4 (via imageio-ffmpeg's bundled ffmpeg binary) + - /demo.gif (via Pillow, downscaled to --gif-width) + +Dependencies (not in requirements.txt - install before running): + pip install imageio-ffmpeg Pillow + +Usage: + python scripts/build_demo_media.py --frames-dir _docs/local/demo-frames --out-dir demo-media +""" + +import argparse +import sys +from pathlib import Path + +import imageio_ffmpeg +from PIL import Image + +DEFAULT_SECONDS_PER_FRAME = 2.5 +DEFAULT_FPS = 30 +DEFAULT_GIF_WIDTH = 800 +DEFAULT_OUT_DIR = "demo-media" + + +def _round_to_even(value): + """ffmpeg's yuv420p output needs even width/height.""" + return value if value % 2 == 0 else value - 1 + + +def load_frame_paths(frames_dir): + frames_dir = Path(frames_dir) + if not frames_dir.exists(): + raise FileNotFoundError(f"Frames directory does not exist: {frames_dir}") + paths = sorted(frames_dir.glob("*.png")) + if not paths: + raise FileNotFoundError(f"No PNG frames found in {frames_dir}") + return paths + + +def build_mp4(frame_paths, out_path, seconds_per_frame, fps): + first = Image.open(frame_paths[0]).convert("RGB") + width = max(2, _round_to_even(first.width)) + height = max(2, _round_to_even(first.height)) + + writer = imageio_ffmpeg.write_frames( + str(out_path), + (width, height), + fps=fps, + codec="libx264", + pix_fmt_out="yuv420p", + ) + writer.send(None) # seed the generator + hold_frames = max(1, round(seconds_per_frame * fps)) + try: + for path in frame_paths: + img = Image.open(path).convert("RGB") + if img.size != (width, height): + img = img.resize((width, height)) + 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): + first = Image.open(frame_paths[0]).convert("RGB") + canvas_width = first.width + canvas_height = first.height + + frames = [] + for path in frame_paths: + img = Image.open(path).convert("RGB") + # Normalize to canvas size first (like build_mp4) + if img.size != (canvas_width, canvas_height): + img = img.resize((canvas_width, canvas_height)) + # Then downscale by 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)))) + frames.append(img) + + duration_ms = int(seconds_per_frame * 1000) + frames[0].save( + out_path, + save_all=True, + append_images=frames[1:], + duration=duration_ms, + loop=0, + optimize=True, + ) + + +def build_demo_media( + frames_dir, + out_dir=DEFAULT_OUT_DIR, + seconds_per_frame=DEFAULT_SECONDS_PER_FRAME, + fps=DEFAULT_FPS, + gif_width=DEFAULT_GIF_WIDTH, +): + 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) + return mp4_path, gif_path + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--frames-dir", required=True, help="Directory of ordered PNG frames" + ) + parser.add_argument( + "--out-dir", + default=DEFAULT_OUT_DIR, + help=f"Output directory for demo.mp4/demo.gif (default: {DEFAULT_OUT_DIR})", + ) + parser.add_argument( + "--seconds-per-frame", + type=float, + default=DEFAULT_SECONDS_PER_FRAME, + help=f"How long each frame is held (default: {DEFAULT_SECONDS_PER_FRAME})", + ) + parser.add_argument( + "--fps", + type=int, + default=DEFAULT_FPS, + help=f"MP4 frame rate (default: {DEFAULT_FPS})", + ) + parser.add_argument( + "--gif-width", + type=int, + default=DEFAULT_GIF_WIDTH, + help=f"GIF output width in pixels, downscaled proportionally (default: {DEFAULT_GIF_WIDTH})", + ) + args = parser.parse_args() + + try: + mp4_path, gif_path = build_demo_media( + args.frames_dir, + args.out_dir, + args.seconds_per_frame, + args.fps, + args.gif_width, + ) + except FileNotFoundError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + print(f"Wrote {mp4_path} ({mp4_path.stat().st_size} bytes)") + print(f"Wrote {gif_path} ({gif_path.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/scripts/demo_capture_checklist.md b/scripts/demo_capture_checklist.md new file mode 100644 index 0000000..2ae39f4 --- /dev/null +++ b/scripts/demo_capture_checklist.md @@ -0,0 +1,71 @@ +# Demo Capture Checklist (#48) + +Ordered screenshots to capture for the demo video/gif. Pairs with +`_docs/DEMO-script.md` (the narration + what each beat shows) and +`scripts/build_demo_media.py` (the frames -> mp4/gif build). + +## Frame naming convention + +`NN-short-name.png` — two-digit zero-padded prefix controls playback +order (frames are sorted by filename). Use the exact names below so they +line up with the DEMO-script beats. + +## Frames to capture + +- [ ] `01-login.png` — `/login`, logged out +- [ ] `02-dashboard.png` — `/dashboard`, logged in as `demo@elevare.ai` +- [ ] `03-qa-math.png` — `/qa`, after asking a math question, answer + visible with KaTeX-rendered equations and a Confidence badge +- [ ] `04-qa-history.png` — `/qa`, revisited/reloaded showing prior + conversation history above the live answer +- [ ] `05-practice.png` — `/practice`, a subject selected and its + AI-generated question list showing +- [ ] `06-practice-answer.png` — `/practice`, after answering a question, + "Correct!" / feedback + explanation visible +- [ ] `07-goals.png` — `/goals`, goals list showing subjects/dates +- [ ] `08-progress.png` — `/progress`, Elo ratings + suggestions visible + +## Before capturing + +1. Run the pre-demo warm-up (two `curl .../health` calls — see + `_docs/DEMO-script.md`) so pages don't load half-rendered while the + API cold-starts. +2. Use a clean browser window sized consistently for every frame (e.g. + 1280x720 or 1920x1080) — the build script handles arbitrary/odd sizes, + but consistent framing looks better in the final video. +3. Save all PNGs into one local, gitignored directory, e.g. + `_docs/local/demo-frames/` — do not commit frames or rendered media. + +## Capture options + +**Option A — manual OS screenshot** +Use the OS screenshot tool (Win+Shift+S on Windows) against the live +frontend at https://elevareai-frontend.onrender.com, logged in as +`demo@elevare.ai`. Crop to the browser viewport for consistency. + +**Option B — automated via chrome-devtools MCP** +When the browser automation tooling (chrome-devtools MCP `take_screenshot`) +has a free/unlocked browser profile available, drive the same 8 beats +programmatically: navigate to each route, wait for content to load +(especially the QA answer and Practice question generation, which call +the AI backend and can take up to ~20s), then screenshot. This wasn't +usable in this session because the local browser profile was locked by +another process — left for the owner to run when free. + +## After capturing + +Run the build: + +```bash +pip install imageio-ffmpeg Pillow +python scripts/build_demo_media.py \ + --frames-dir _docs/local/demo-frames \ + --out-dir demo-media \ + --seconds-per-frame 2.5 \ + --fps 30 \ + --gif-width 800 +``` + +Check `demo-media/demo.mp4` and `demo-media/demo.gif`. Both are +gitignored — decide separately (see `_docs/DEMO-script.md`) whether final +media ships as a repo commit or a release asset. diff --git a/tests/test_build_demo_media.py b/tests/test_build_demo_media.py new file mode 100644 index 0000000..bcaaa0e --- /dev/null +++ b/tests/test_build_demo_media.py @@ -0,0 +1,125 @@ +"""Tests for scripts/build_demo_media.py. + +Validates the screenshots -> mp4/gif pipeline against synthetic frames +(solid-color PNGs), since real app screenshots require a live browser +session. See scripts/build_demo_media.py and _docs/DEMO-script.md. +""" + +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("PIL", reason="demo tooling dep (Pillow) not installed") +pytest.importorskip("imageio_ffmpeg", reason="demo tooling dep not installed") + +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 + + +def _make_frames(tmp_path, count=3, size=(1280, 720)): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + colors = [(200, 0, 0), (0, 200, 0), (0, 0, 200)] + for i in range(count): + img = Image.new("RGB", size, colors[i % len(colors)]) + img.save(frames_dir / f"{i:02d}-frame.png") + return frames_dir + + +def test_build_demo_media_produces_mp4_and_gif(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 + ) + + assert mp4_path.exists() + assert mp4_path.stat().st_size > 0 + + # Verify mp4 is decodable by reading metadata + import imageio_ffmpeg + + reader = imageio_ffmpeg.read_frames(str(mp4_path)) + try: + metadata = next(iter(reader)) + assert isinstance(metadata, dict) + assert metadata.get("source_size") is not None + assert metadata.get("fps") is not None + finally: + reader.close() + + assert gif_path.exists() + assert gif_path.stat().st_size > 0 + + with Image.open(gif_path) as gif: + assert gif.is_animated + assert gif.n_frames == 3 + + +def test_build_demo_media_handles_odd_dimensions(tmp_path): + frames_dir = _make_frames(tmp_path, count=2, size=(1281, 721)) + 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 + ) + + assert mp4_path.exists() + assert mp4_path.stat().st_size > 0 + + # Verify mp4 is decodable by reading metadata + import imageio_ffmpeg + + reader = imageio_ffmpeg.read_frames(str(mp4_path)) + try: + metadata = next(iter(reader)) + assert isinstance(metadata, dict) + assert metadata.get("source_size") is not None + assert metadata.get("fps") is not None + finally: + reader.close() + + assert gif_path.exists() + assert gif_path.stat().st_size > 0 + + +def test_build_demo_media_handles_mismatched_frame_sizes(tmp_path): + """Test that gif normalizes frames to common canvas size before downscaling.""" + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + # Create two frames with different sizes + img1 = Image.new("RGB", (1280, 720), (200, 0, 0)) + img1.save(frames_dir / "00-frame.png") + img2 = Image.new("RGB", (1000, 700), (0, 200, 0)) + img2.save(frames_dir / "01-frame.png") + + 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 + ) + + # Verify gif builds successfully with normalized frames + with Image.open(gif_path) as gif: + assert gif.is_animated + assert gif.n_frames == 2 + # All frames should have the same canvas size (first frame's dims, downscaled) + for frame_idx in range(gif.n_frames): + gif.seek(frame_idx) + assert gif.width == 400 # downscaled to gif_width + + +def test_load_frame_paths_missing_dir_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_frame_paths(tmp_path / "does-not-exist") + + +def test_load_frame_paths_empty_dir_raises(tmp_path): + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + with pytest.raises(FileNotFoundError): + load_frame_paths(empty_dir)