WorkWingman — Study Visuals (Technical)¶
Optional, original AI-generated illustrations for a study concept — the illustrated-explanation spirit of books like Grokking Algorithms (a diagram that makes an idea click at a glance), never their artwork — plus an optional sharpen/enhance step and an optional image-to-video "animate" step. Feeds prep-studio flashcards, the Claude Project study workspace, and the NotebookLM export as optional visual enrichment.
Friendlier version: ../plain/study-visuals.md
The one rule that matters more than any other: copyright¶
This feature generates ONLY original illustrations from a text prompt it builds itself. It never reproduces, requests, or approximates any copyrighted book's actual images or figures.
The inspiration for "visual study aids" is the pedagogy of illustrated-explanation books — plain, labeled diagrams that make a hard concept click — not their artwork. Concretely:
ConceptVisualPromptBuilder(src/WorkWingman.Infrastructure/Visuals/ConceptVisualPromptBuilder.cs) describes the concept generically (what to depict, generic style words like "clean flat educational diagram," "labeled," "high contrast") plus the study context. It never names a book, author, or illustrator, never says "in the style of<work>," and never asks to "recreate figure N."- Every prompt ends with an explicit instruction to the image model: "Depict the concept itself only — do not include any book covers, page layouts, logos, or any other copyrighted artwork; this must be a wholly original image."
ConceptVisualPromptBuilderTestsasserts, as a regression guard, that no generated prompt ever contains a list of known copyrighted-work/illustrator names (deliberately including Grokking Algorithms and its author, to prove the inspiration never becomes a literal reference) or phrases like "in the style of" / "recreate figure."
If a future change to the prompt builder is tempted to reference a specific book, illustrator, or "style of X," stop — that is exactly the line this feature is built not to cross.
Provider tiers¶
Every provider below is paid, BYO-key, and optional — the user brings their own API key from their own account with that provider, config-gated the standard way (absent key → that source is simply unavailable; the rest of Study keeps working). WorkWingman never calls any of these APIs without a user-provided key, and never pays for or shares access to a key on the user's behalf.
| Tier | Provider | What it does | Cost |
|---|---|---|---|
| Generate (mid) | Google "Nano Banana" (Gemini 2.5 Flash Image) | Text-to-image, original illustration | ~$0.02–$0.04/image |
| Generate (mid) | OpenAI (gpt-image-1.5) | Text-to-image, original illustration | ~$0.01–$0.25/image |
| Enhance (pricier, optional add-on) | Magnific (image-upscaler API) | Sharpens/upscales an already-generated illustration — not a generator | credit-subscription, ~$0.05/standard upscale-equivalent |
| Video (built) | Magnific (image-to-video / Kling API) | Animates an already-generated illustration into a short original clip | credit-subscription (Premium 20K / Premium+ 45K / Pro 300K credits per month); video costs more credits per job than an upscale |
| Video (documented only, not built) | Revid.ai | Paid AI video generation from a script | $39–$199/month subscription, credit-metered |
Research verdict: the two image-GENERATION APIs¶
Both are paid, BYO-key services — the user brings their own API key from their own account with that provider. WorkWingman never calls either API without a user-provided key, and never pays for or shares access to a key on the user's behalf.
Google "Nano Banana" = Gemini 2.5 Flash Image¶
Verified against ai.google.dev, 2026:
| Endpoint | POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent |
| Auth | Header x-goog-api-key: {key} |
| Model id | gemini-2.5-flash-image (this is the model nicknamed "Nano Banana"; Google has since introduced newer "Nano Banana 2" generations — the model id is a single constant, GeminiImageSource.DefaultModel, so bumping it later is a one-line change) |
| Request | { "contents": [{ "parts": [{ "text": prompt }] }] } |
| Response | candidates[0].content.parts[] — the image part carries inlineData.data (base64) + inlineData.mimeType; a text part may also be present and is ignored |
| Cost (verified 2026) | ~$0.039/image standard tier (1290 output tokens × $30/1M output-token rate), ~$0.0195/image via the batch API — paid, BYO key |
OpenAI images = the gpt-image-* family¶
Verified against developers.openai.com, 2026:
| Endpoint | POST https://api.openai.com/v1/images/generations |
| Auth | Header Authorization: Bearer {key} |
| Model id | gpt-image-1.5 (OpenAiImageSource.DefaultModel) |
| Request | { "model", "prompt", "n", "size", "response_format": "b64_json" } |
| Response | { "data": [ { "b64_json": "..." } ] } |
| Cost (verified 2026) | roughly $0.01–$0.25/image depending on quality tier and resolution — paid, BYO key |
Model-lifecycle note (important, checked live during this build): OpenAI's gpt-image-1 is
scheduled for deprecation (Oct 23, 2026), and DALL·E 2/3 were already removed from the API in May
2026. gpt-image-2 is the current flagship. This integration deliberately targets gpt-image-1.5
— a current, non-deprecated model — rather than the gpt-image-1 name mentioned in the original
brief, precisely because that name is being retired. The request/response contract (endpoint,
JSON shape) is stable across the whole gpt-image-* family, so bumping the model id later (e.g.
to gpt-image-2) is a one-line change in OpenAiImageSource.DefaultModel.
Research verdict: Magnific (image enhance/upscale — built)¶
Magnific (now unified under Freepik's platform, as of an April 2026
domain redirect) is best known for high-resolution upscaling/enhancement, not from-scratch
text-to-image generation — its API surface (verified via the machine-readable
docs.magnific.com/llms.txt) includes both generation
endpoints (Mystic, Flux, Seedream models) and dedicated image-editing endpoints
(upscaler-creative, upscaler-precision, relight, style-transfer). Since this feature already
has two from-scratch generators, Magnific is wired as the sharpen/enhance step on top of an
already-generated illustration — matching what it's actually known for and avoiding a third
redundant generator.
| Endpoint | POST https://api.magnific.com/v1/ai/image-upscaler (create), GET https://api.magnific.com/v1/ai/image-upscaler/{task_id} (poll status) |
| Auth | Header x-magnific-api-key: {key} |
| Request | { "image": base64, "scale_factor": "2x" } (input is the already-generated illustration's bytes, base64-encoded) |
| Response | Asynchronous task model: { "data": { "task_id", "status": "IN_PROGRESS\|COMPLETED\|FAILED", "generated": [url,...] } } — the create call returns immediately with a task_id; the adapter polls the status endpoint until COMPLETED/FAILED or a bounded poll budget is exhausted, then downloads the first generated image URL |
| Cost (verified 2026) | Credit-subscription model: Premium (20K credits/mo), Premium+ (45K credits/mo), Pro (300K credits/mo, 3 keys); roughly $0.05 per standard upscale in credit terms |
Pixabay was evaluated and rejected for this feature. Pixabay's public API
(pixabay.com/api/docs) is a stock-media search endpoint (find existing royalty-free photos
and videos), not an AI image generator — Pixabay's AI image generator is a website-only feature
with no documented public/programmatic API. Since this feature's premise is original AI
illustration (not stock-photo search), and there was no verifiable generation API to integrate,
Pixabay was not added in any form.
Research verdict: Magnific video (image-to-video — BUILT)¶
Magnific DOES offer a video API. Its llms.txt
(docs.magnific.com/llms.txt) lists a large set of async,
task-based video endpoints — image-to-video (Kling v2.1/2.5/2.6-pro, MiniMax Hailuo, WAN i2v,
RunWay Gen4, Seedance, PixVerse, OmniHuman) and text-to-video (WAN t2v, LTX). They share the exact
same auth (x-magnific-api-key) and async response shape ({ data: { task_id, status,
generated: [url,...] } }) as the image-upscaler endpoint that was verified in full. So this
feature wires Magnific video as the video tier: it animates an already-generated illustration
into a short original concept clip (image-to-video, kling-v2-1-pro — a bumpable constant).
| Endpoint | POST https://api.magnific.com/v1/ai/{model} (create), GET .../{model}/{task_id} (poll) |
| Auth | Header x-magnific-api-key: {key} (a separate BYO key row, magnific-video) |
| Request | { "image": base64, "prompt": text } (the illustration's bytes + its concept prompt) |
| Response | Async: { data: { task_id, status, generated: [videoUrl,...] } } — poll to COMPLETED/FAILED, then download the mp4 |
| Cost | Credit-subscription (Premium/Premium+/Pro tiers); a video job costs more credits than an image upscale |
Verification caveat (documented honestly): the exact per-model optional request fields
(duration, aspect ratio) for the specific video endpoints were not individually retrievable from
the public docs at build time (the llms.txt index lists the endpoint paths; a concrete
video request-body example wasn't in the fetched llms-full.txt). Because the auth, the async
task response, and the poll pattern are verified-identical to the upscaler, MagnificVideoSource
sends the conservative, task-API-consistent { image, prompt } body and the model id is a single
bumpable constant — the same forward-compatible approach used for the image model ids. If a
specific model's full schema is later confirmed, add the optional fields there.
Copyright: the input is an ORIGINAL illustration this app generated from a generic concept
prompt — animating it yields an original clip, never a copy of copyrighted footage. StudyVideo
carries the same Concept/Prompt transparency fields as StudyVisual, and video generation is
gated + path-free + local-storage exactly like the image tiers.
Research verdict: Revid.ai (AI video — documented only, not built)¶
Revid.ai is a paid AI video-generation product (script/link/recording →
short-form video, avatars, voiceovers). Verified via revid.ai/docs.
It is a different shape from the Magnific video path built above — text/script-to-video with
voice/captions/avatars rather than animating an existing illustration — and is the priciest tier
(a monthly subscription, not simple per-call pricing). It is documented here but deliberately
not built; a future contributor could add it behind a revid-video provider id following the
same async-create-and-poll shape.
| Base URL | https://www.revid.ai/api/public/v3 |
| Auth | Header key: {your-api-key} |
| Endpoint | POST /render — { "workflow": "script-to-video", "source": { "text": "..." }, "webhookUrl": "..." } |
| Response | Asynchronous: returns a pid, poll GET /status?pid=... or receive a webhook callback |
| Cost (verified 2026) | Subscription-gated API access: Growth $39/month (2,000 credits/mo) or Ultra $199/month (12,000 credits/mo); ~10 credits + generation charges per render |
This is documented as an optional future stretch and deliberately not built in this feature:
it is the priciest tier (monthly subscription, not simple per-call pricing), video adds
meaningfully more surface area (script generation, voice, captions, rendering time), and the
brief's scope for this pass is illustrations. A future contributor adding Revid.ai should follow
the same shape as MagnificSource/IStudyVisualEnhancer — async create + poll, config-gated on
a revid-video provider id — but this branch does not implement it.
The generator seam¶
public interface IStudyVisualGenerator
{
StudyVisualSource Source { get; }
Task<bool> IsAvailableAsync(CancellationToken ct = default);
Task<StudyVisualResult> GenerateAsync(ConceptVisualRequest request, CancellationToken ct = default);
}
(src/WorkWingman.Core/Interfaces/IStudyVisualGenerator.cs)
Two adapters implement it:
GeminiImageSource(src/WorkWingman.Infrastructure/Visuals/GeminiImageSource.cs)OpenAiImageSource(src/WorkWingman.Infrastructure/Visuals/OpenAiImageSource.cs)
Both follow the same shape: resolve the key via IApiKeyProvider, build the prompt via
ConceptVisualPromptBuilder, POST through Flurl, decode the base64 image, save it locally via
StudyVisualStore, and return a StudyVisualResult. Any failure — no key, HTTP error,
malformed/empty response — degrades to StudyVisualResult.Unavailable(message); neither
adapter ever throws out of GenerateAsync.
No retries on the paid POST. The generation call uses ResiliencePipelines.NoRetry (timeout
only), not the retrying Default pipeline every other REST client uses — because a generation
request is non-idempotent and billed. If it timed out or hit a transient 5xx after the provider
had already accepted the job, a retry could create several paid images for one click. A failure
surfaces once and degrades gracefully. (Magnific's safe idempotent status-poll and image-download
GETs keep the retrying Default pipeline; only its billed create POST is NoRetry.)
StudyVisualService
(src/WorkWingman.Infrastructure/Visuals/StudyVisualService.cs)
orchestrates both sources: GetAvailableSourcesAsync reports which ones currently have a usable
key (drives the frontend gate), GenerateAsync/RegenerateAsync/DeleteAsync persist the
resulting gallery in a single local JSON index (study-visuals) via the existing
LocalJsonStore, scoped per study item id.
The enhancer seam (Magnific)¶
Sharpening is a distinct operation from generating — it takes an existing StudyVisual and
produces a new, sharper one — so it gets its own small interface rather than overloading
IStudyVisualGenerator:
public interface IStudyVisualEnhancer
{
Task<bool> IsAvailableAsync(CancellationToken ct = default);
Task<StudyVisualResult> EnhanceAsync(StudyVisual original, CancellationToken ct = default);
}
(src/WorkWingman.Core/Interfaces/IStudyVisualEnhancer.cs)
MagnificSource
(src/WorkWingman.Infrastructure/Visuals/MagnificSource.cs)
is the only implementation today. Enhancing never replaces or deletes the original — the
enhanced result is a new StudyVisual with Source = MagnificEnhanced and
EnhancedFromVisualId pointing back at the original, so both stay in the gallery for the user to
compare. StudyVisualService.EnhanceAsync(visualId) looks up the stored original, tries each
registered (and available) enhancer in order until one succeeds, and persists only a successful
result — same "never persist a failure" rule as generation.
The video seam (Magnific image-to-video)¶
The video tier is a third small interface — output is a video file, not an image, and it's the priciest, most optional aid:
public interface IStudyVideoGenerator
{
StudyVideoSource Source { get; }
Task<bool> IsAvailableAsync(CancellationToken ct = default);
Task<StudyVideoResult> GenerateAsync(StudyVisual original, CancellationToken ct = default);
}
(src/WorkWingman.Core/Interfaces/IStudyVideoGenerator.cs)
MagnificVideoSource
(src/WorkWingman.Infrastructure/Visuals/MagnificVideoSource.cs)
animates a stored illustration into a short clip via Magnific's image-to-video (Kling) endpoint,
following the same async create-then-poll-then-download shape as the upscaler (the billed create
POST uses the NoRetry pipeline; the poll + download use Default). The result is a StudyVideo
(source MagnificVideo, FromVisualId set) persisted in a separate study-videos JSON index
with its own concurrency gate; the image gallery is untouched. StudyVisualService.GenerateVideoAsync
/ GetVideosAsync / DeleteVideoAsync mirror the image methods.
Config-gating: never call a paid API without a user key¶
IApiKeyProvider
(src/WorkWingman.Core/Interfaces/IApiKeyProvider.cs)
is the contract config-gated adapters depend on: null means "not configured," and a missing key
never throws (the vault-first, config-fallback routing below is the concrete implementation's
behavior, not part of the interface). This feature was built standalone in its own worktree behind a tiny
config-only stub; that stub was deleted when feature-apikey-onboarding merged, and the four
visual sources now depend on the real vault-first implementation,
ApiKeyProvider — vault
entry first, then an IConfigurationLookup fallback over appsettings + WorkWingman:* config/env
keys, and never the config fallback in a sandbox environment (a fresh-user world must not inherit a
developer's real keys). The four provider ids live in
ApiKeyProviderRegistry,
each with its own config key:
| Provider id | Registry ConfigKey |
Free tier |
|---|---|---|
gemini-image |
WorkWingman:GeminiImageApiKey |
Yes (rate-limited) |
openai-image |
WorkWingman:OpenAiImageApiKey |
No |
magnific-image |
WorkWingman:MagnificImageApiKey |
No |
magnific-video |
WorkWingman:MagnificVideoApiKey |
No |
(A fifth, revid-video, could be added the same way if Revid.ai is ever built — see the Revid.ai
research verdict above.) The image and video Magnific keys are separate rows deliberately: the same
Magnific account key can be pasted into both, but keeping them distinct lets a user enable image
sharpening without opting into the pricier video tier.
Absent key → no-op, not a free call. Both GenerateAsync implementations check the key
before building a request. No key configured for a source means that source is simply excluded
from GetAvailableSourcesAsync, and a direct GenerateAsync call against an unconfigured source
returns StudyVisualResult.Unavailable("Add a Gemini/OpenAI API key in Integrations to enable
…") — never a network call. StudyVisualGeneratorTests asserts the HttpTest call log is empty
in this case for both adapters.
Local storage¶
StudyVisualStore
(src/WorkWingman.Infrastructure/Visuals/StudyVisualStore.cs)
writes generated image bytes under the existing app data directory
(%USERPROFILE%\Wingman\data\study-visuals\, alongside every other local-first JSON store in this
app) and returns the local file path. StudyVisualService persists a StudyVisual record — id,
concept, the exact prompt sent, source, local path, the study item it's attached to, and a
timestamp — in a single JSON index (study-visuals.json), following the same
LocalJsonStore pattern as every other collection in this codebase.
public class StudyVisual
{
public string Id { get; set; }
public string Concept { get; set; }
public string Prompt { get; set; } // exact prompt sent, kept for transparency/regenerate
public StudyVisualSource Source { get; set; }
public string LocalImagePath { get; set; }
public string? StudyItemId { get; set; }
public DateTimeOffset AsOf { get; set; }
}
Privacy: what actually leaves the machine¶
Generation (Gemini/OpenAI): only the concept prompt text goes to the user's own chosen image API — nothing else about the user, ever. The request carries the generated prompt string (a generic description of the concept; the app sends an empty context, so it's just the typed concept) plus the user's own API key. No resume data, no job description, no personal profile fields, no other app data. The image comes back and is written to local disk.
Enhance / Animate (Magnific): these are honestly different — to sharpen or animate an illustration, the already-generated illustration's image bytes (plus its prompt) are uploaded to the user's own Magnific account to process. That is the generated study image, not the user's personal data — but it is an upload, so the UI copy is explicit about it (a separate line appears whenever a Magnific key is configured) rather than claiming "nothing leaves the machine." Still the user's own key, own account, own cost; still no resume/JD/profile data.
In all cases the result (image or video) is written to local disk and is never uploaded anywhere beyond the single API call to the user's own chosen provider.
Integration wiring (feeds flashcards / Claude Project / NotebookLM)¶
This feature defines small, stable input contracts other in-flight branches integrate against:
- Study feature (this branch, wired today):
Studyfrontend component readsGET /api/study-visuals/available-sourcesto gate the "Visual study aids" section andGET /api/study-visuals/{studyItemId}for the per-job gallery, keyed byjobIdas thestudyItemId. - prep-studio flashcards (parallel branch,
feature-prep-studio): a flashcard is a naturalstudyItemId— pass the flashcard id asConceptVisualRequest.StudyItemIdand the flashcard's front-text asConceptto attach an illustration to that specific card. Documented here as the wiring contract; the prep-studio branch owns rendering the attached visual on its own card UI. - Claude Project / NotebookLM export (parallel branches,
feature-notebooklm): when building a study pack for export, include eachStudyVisual.LocalImagePath+Conceptalongside the existing study resources so the grounded workspace can reference the illustration file; thePromptfield is kept specifically so an export can show "this is what was asked for" next to the image for full transparency.
None of the above requires this branch to depend on the others' code — the contract is just
"pass a studyItemId string that means something to you" and "read StudyVisual.LocalImagePath
off disk," which is why this branch compiles and tests standalone.
API¶
| Route | Method | Purpose |
|---|---|---|
/api/study-visuals/available-sources |
GET | Which sources (GeminiNanoBanana, OpenAiImages) currently have a usable key |
/api/study-visuals/enhancement-available |
GET | Whether a Magnific key is configured (drives the "Sharpen" button) |
/api/study-visuals/{studyItemId} |
GET | The gallery for one study item, newest first |
/api/study-visuals/{visualId}/image |
GET | Streams the visual's image bytes (never a raw file:// path — see below) |
/api/study-visuals/generate |
POST | ConceptVisualRequest body → StudyVisualResult |
/api/study-visuals/{visualId}/regenerate |
POST | Re-run the same concept/source, adds alongside (does not replace) the prior visual |
/api/study-visuals/{visualId}/enhance |
POST | Sharpen/upscale via Magnific, adds alongside (does not replace) the original |
/api/study-visuals/{visualId} |
DELETE | Remove the visual and its local image file |
/api/study-visuals/video-available |
GET | Whether a Magnific video key is configured (drives the "Animate" button) |
/api/study-visuals/videos/{studyItemId} |
GET | The generated-video gallery for one study item, newest first (path-free) |
/api/study-visuals/video/{videoId}/file |
GET | Streams the video's mp4 bytes (never a raw file:// path) |
/api/study-visuals/{visualId}/video |
POST | Animate a visual into a short clip via Magnific → StudyVideoResult |
/api/study-visuals/video/{videoId} |
DELETE | Remove the video and its local file |
Image serving, not raw file paths. The frontend renders each visual via
/api/study-visuals/{visualId}/image rather than binding a file:/// URL directly — browsers
(and the packaged Electron renderer) don't load arbitrary file:// URLs from a web page, so a
raw local path would silently fail to render. StudyVisualsController.GetImage also defends in
depth: it resolves the stored path to an absolute path and refuses to serve anything that doesn't
resolve under StudyVisualStore.VisualsDirectory, even though every path stored today was written
by StudyVisualStore.SaveAsync itself.
The absolute local path never crosses the API. The internal StudyVisual model carries
LocalImagePath, but every API response projects to a path-free view (StudyVisualView /
StudyVisualResultView) that omits it entirely — the gallery GET, and the generate/regenerate/
enhance results. Because the loopback API allows any origin, this keeps a page that can reach the
API from learning absolute local filesystem paths; the renderer only ever needs the image
endpoint URL (built from the visual's id), never a path.
Token gating. Every endpoint that spends the user's paid quota or mutates the gallery
(generate, regenerate, enhance, delete) is gated on the per-launch ApiToken — the same
protection the dependency installer uses — so a hostile page in the same browser cannot drive a
cross-origin POST against the loopback API and burn the user's Gemini/OpenAI/Magnific credits.
GET (list/detect/image) stays open. The Electron preload forwards the token as the
X-WorkWingman-Token header (see the frontend apiTokenInterceptor); the Angular dev origin
http://localhost:4200 is also trusted for development.
Frontend¶
Study (frontend/src/app/features/study/study.ts)
adds a "Visual study aids (optional)" card. visualsEnabled (a computed signal) is true only when
getAvailableVisualSources() returns at least one source; the disabled state shows "Connect a
Gemini or OpenAI key in Integrations to enable illustrations for study concepts" instead of the
generate control. Only the generate/regenerate controls are gated on a configured key — an
existing gallery of previously-generated visuals and videos stays visible (and deletable, a local
op) even if the key is later removed. Copy is explicit that this is optional, uses the user's own
subscription, the result is an original AI illustration that "may contain errors — verify facts,"
and (per the privacy section above) only the concept text leaves the machine.
Each gallery tile gets a "Sharpen" button (shown only when enhancementAvailable is true and
the tile isn't already an enhanced result) and an "Animate" button (shown only when
videoAvailable is true, on non-enhanced tiles) — clicking Sharpen prepends the sharpened copy to
the image gallery; clicking Animate prepends the clip to a separate video gallery rendered
below with an HTML5 <video controls> player. Enhanced tiles show only Delete — Regenerate,
Sharpen, and Animate are hidden on them, because regenerate has no generator for the
MagnificEnhanced source (you regenerate/sharpen/animate the original, not the sharpened copy).
Regenerate, Sharpen, and Animate each guard against a double-click (disabled + in-flight signal)
so one accidental repeat click never starts a second paid job.
Generation sends only the concept text the user typed — context is deliberately left empty
rather than including the gap analysis or any JD/resume-derived prep, so the "nothing but the
concept text you type ever leaves your machine" copy stays literally true.
Tests¶
ConceptVisualPromptBuilderTests.cs— determinism (same input → byte-identical output, including across 25 randomized Bogus-generated concepts), the copyright-clean regression guard (asserts the prompt never contains a list of known copyrighted-work/illustrator names or "in the style of"/"recreate figure" phrasing), exact full-prompt-text assertions, and edge cases (empty concept throws, null request throws, blank context omits the context sentence, whitespace collapsing).StudyVisualGeneratorTests.cs—GeminiImageSourceTests+OpenAiImageSourceTestsagainst FlurlHttpTestfixtures returning a tiny fake 1×1 PNG: config-gate no-op (asserts the HttpTest call log is empty when unkeyed), exact endpoint/verb/header assertions, graceful degradation on an empty/malformed response, on malformed (non-base64) image data (regression test — this previously letFormatExceptionescape uncaught), and on HTTP error status codes (with exact user-facing messages) — both suites drive the adapters through aFakeApiKeyProviderstand-in for the real vault-first key provider.MagnificSourceTests.cs— the async create-then-poll flow against FlurlHttpTest: config-gate no-op, the full POST-then-GET-until-COMPLETED sequence with a real download of the final image,FAILEDstatus, an exhausted poll budget (proves the loop is bounded and never hangs), aCOMPLETEDstatus with no generated images, a missingtask_id, and an HTTP error — all degrade toUnavailable, never throw. Every test setsPollDelay = TimeSpan.Zeroso the suite stays fast.MagnificVideoSourceTests.cs— the image-to-video create-then-poll flow: config-gate no-op, missing-original guard, the full POST-then-poll-until-COMPLETED-then-download-mp4 sequence,FAILEDstatus, an exhausted poll budget (bounded, never hangs), a missingtask_id, and an HTTP error — all degrade toUnavailable, never throw.PollDelay = TimeSpan.Zerokeeps it fast.StudyVisualsControllerTests.cs— the image- and video-serving endpoints (real bytes + correct content-type, 404 for an unknown id, a missing-on-disk file, or a stored path that resolves outside the visuals directory — including a sibling directory that shares the prefix — the defense-in-depth guard); the path-free gallery views (neitherStudyVisualViewnorStudyVideoViewhas a local-path property); and the app-token gate — generate/regenerate/enhance/delete + generate-video/delete-video return401without the token and proceed with it.StudyVisualServiceTests.cs— orchestration: only available sources/enhancers/video-generators are reported, generate/regenerate/delete/enhance/animate round-trip through a real (temp-directory)LocalJsonStore+StudyVisualStore, per-study-item gallery scoping, newest-first ordering, that a failure is never persisted, that enhancing/animating adds alongside the original (video goes to a separatestudy-videosindex, image gallery untouched), that a failing enhancer falls back to the next, and that concurrent generations all persist (the index gate prevents lost updates).study.spec.ts— the visual study aids describe block: gate on/off for generation, enhancement, and video; gallery + video-gallery load; generate/regenerate/delete/enhance/animate state updates; the paid-call double-click guards; that generation is skipped client-side when no source is available or the concept is blank; and that the image/video URL helpers never produce afile://path.api-token.interceptor.spec.ts— the token is attached to install + study-visuals generate/regenerate/enhance/delete, but never to a study-visuals GET or unrelated calls, and never when no token is present (dev server).- Stryker.NET mutation score for
**/Visuals/*.cs: 92.75% (178/192 mutants killed) using a scoped config,stryker-visuals-scoped.json, well above the 80% "high" threshold. The fullmutatelist instryker-config.jsonalso includes**/Visuals/*.csfor the regular scheduled full run.
Related docs¶
- Plain-language version: ../plain/study-visuals.md
- Testing philosophy and tooling: testing.md
- Integration keys / BYO-key pattern: see
ApiKeyProviderandApiKeyProviderRegistry(the real vault-first seam this feature depends on) - References / learn-more: references.md