Skip to content

Visual Explainer

Visual Explainer turns one canonical Course OS topic into a short, source-grounded, privately rendered video. It is a server-side preview capability: there are no course-specific routes, renderers, or free-form prompt-to-video inputs.

Learner flow

  1. Open Visual explainer from any topic and choose intuition, worked example, or connections.
  2. Study Hub reads the canonical topic revision, retrieves validated sources, generates a strict two-to-four-scene storyboard, and runs a separate grounded mathematics/pedagogy review.
  3. Review the storyboard and explicitly choose Render explanation.
  4. The durable job moves through queued, preparing, rendering, encoding, and verification states. The sheet follows real server-sent state updates and survives page or container restarts.
  5. A completed MP4 plays through an authenticated, byte-range endpoint with WebVTT captions and a poster. Learners can report a misleading step without publishing the artifact as course content.

Completed renders are reused by semantic cache key: course, topic, canonical revision, teaching emphasis, storyboard recipe, renderer version, and output profile. A cache hit is cloned into a new owner-scoped job, so artifact authorization never depends on another learner's job row.

Trust and access boundary

  • The owner is eligible while the feature is enabled. Additional users are explicit server-side user-ID or email allowlists.
  • Every job is tied to auth.users; RLS allows authenticated users to select only their own rows. Direct inserts and updates are service-only.
  • Daily preview generation is checked against durable job rows as well as a fast in-process guard.
  • Every storyboard claim cites supplied source IDs. Invented IDs, detached claim citations, and a failed independent review are rejected before a job exists.
  • The renderer receives only the job ID, cache key, and schema-validated primitive JSON through a private bearer-authenticated app endpoint. It receives no Supabase, AI, account, or course-store credentials.
  • The worker executes fixed Python only. Generated Python is never accepted; plot expressions use a constrained AST, math commands use an allowlist, and text is length-bounded.
  • Artifact paths are cache-key scoped and validated twice. Playback requires the signed-in job owner and never exposes the host filesystem path.

Runtime architecture

public.visual_explainer_jobs holds storyboards, review evidence, progress, leases, retry state, reports, and private artifact metadata. public.claim_visual_explainer_job is a service-role-only, security-invoker RPC that atomically claims one queued or expired job with FOR UPDATE SKIP LOCKED. The default maximum is two attempts.

The app and worker share ${DATA_DIR}/visual-explainers. The worker is a separate Compose service on an internal-only network, has no published port, uses a read-only root filesystem, drops all Linux capabilities, and is capped at 1.5 CPUs, 2 GB RAM, and 256 PIDs. Production rendering is concurrency one. The base image is Manim Community v0.21.0 pinned by manifest digest.

Render modes

The worker has two render modes, selected by VISUAL_EXPLAINER_RENDER_MODE:

  • inprocess (default): the worker executes the trusted render_job.py primitive renderer itself. This remains the active path.
  • spool: the supervisor writes the scene source into a host-side spool directory and waits for a verdict instead of executing scene code itself. This seam is staged but not yet active; it becomes the default only when the generated-code pipeline replaces the storyboard primitives.

The spool contract (services/visual-explainer-worker/renderspool.py) is the only thing the supervisor and the host render runner share. The supervisor writes scene.py followed by request.json; the runner validates every field, executes the scene in a throwaway container, and writes back status.json. A systemd path unit watches the spool on the host, so no container ever holds a Docker socket.

The throwaway renderer runs with fixed, non-negotiable isolation flags: --network none, --user 1001:1001, --cap-drop ALL, --security-opt no-new-privileges:true, --memory 2g, --cpus 1.5, --pids-limit 256, and --rm, plus a read-only root and a quota-bounded tmpfs. It receives no environment, no token, and no data mount — only its own request directory — so generated code inherits none of the supervisor's powers.

Configuration

VISUAL_EXPLAINER_ENABLED=1
VISUAL_EXPLAINER_ALLOWED_USER_IDS=
VISUAL_EXPLAINER_ALLOWED_EMAILS=
VISUAL_EXPLAINER_DAILY_LIMIT=50
VISUAL_EXPLAINER_WORKER_TOKEN=<at-least-32-random-characters>
VISUAL_EXPLAINER_POLL_SECONDS=3
VISUAL_EXPLAINER_RENDER_TIMEOUT_SECONDS=300
VISUAL_EXPLAINER_RENDER_MODE=inprocess

The app also needs the normal signed-in Supabase runtime values. Locally, set an absolute VISUAL_EXPLAINER_FS_ROOT; VPS Compose fixes it to /data/study-hub/visual-explainers.

VPS acceptance

Apply and inspect the migration before enabling the worker:

npx supabase db push --dry-run
npx supabase db push

Set the protected test environment values, keep the production switch off, and deploy the test branch through the normal CI-gated workflow. The deployment script enables the Compose profile only when VISUAL_EXPLAINER_ENABLED=1, validates required secrets, builds the pinned worker, waits for both containers, and retains the existing image rollback path.

Acceptance must cover all three emphases, cache reuse, a renderer retry, mobile and keyboard use, caption display, authenticated seeking, cross-account denial behavior, an issue report, and a restart while a job is queued. While rendering the benchmark and one real job, record worker CPU, RAM, wall time, image size, temporary disk use, MP4 size, and concurrent /api/healthz latency.

Run focused checks while iterating:

npm exec vitest run src/ai/visual-explainer src/ai/application/visual-explainer.server.test.ts src/ai/ui/VisualExplainer.test.tsx
python -m py_compile services/visual-explainer-worker/worker.py services/visual-explainer-worker/render_job.py
docker compose --env-file .env.vps.example -f compose.vps.yaml config --quiet

The delivery gate remains npm run check; local Supabase database tests and an actual worker image render additionally require a running Docker engine.