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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
88 changes: 88 additions & 0 deletions _docs/DEMO-script.md
Original file line number Diff line number Diff line change
@@ -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`.
166 changes: 166 additions & 0 deletions scripts/build_demo_media.py
Original file line number Diff line number Diff line change
@@ -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:

- <out-dir>/demo.mp4 (via imageio-ffmpeg's bundled ffmpeg binary)
- <out-dir>/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()
71 changes: 71 additions & 0 deletions scripts/demo_capture_checklist.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading