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¶
- Open Visual explainer from any topic and choose intuition, worked example, or connections.
- 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.
- Review the storyboard and explicitly choose Render explanation.
- 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.
- 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 trustedrender_job.pyprimitive 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:
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.