Skip to content

NF webgl views animation gui - #752

Merged
kroq-gar78 merged 15 commits into
mainfrom
webgl-views-animation-gui
Oct 1, 2026
Merged

kroq-gar78 merged 15 commits into
mainfrom
webgl-views-animation-gui

Conversation

@marklescroart

Copy link
Copy Markdown
Contributor

Saving views and building animations used to require driving the viewer from Python (_capture_view, _set_view, make_movie_views), and a subject's views/ folder was invisible from the browser. This branch moves that loop into the viewer. It also makes flat renders (when they are part of an animation) match quickflat.make_png on request.

What's new for users

Views in the browser

  • camera › views lists every view saved in the subject's filestore views/ folder, including in make_static exports.
  • save view captures the current view in the browser. JSMixer.retrieve_new_views() returns it to Python, and save_new_views() writes it to the filestore, where every later viewer of that subject picks it up. [NOTE, Mark rephrasing Claude's text: to permanently save new views, you have to call save_new_views() method of the python handle to the webgl viewer. This is for safety (we can' t have web page controlling python process on host), and may be a model for saving other elements added in webgl viewers (e.g. ROIs, sulci, other labels).
  • Every subject gets nine default views: dorsal, ventral, lateral_left and lateral_right, each on the fiducial and inflated surfaces, plus flat. A view saved under one of those names replaces that subject's default.
  • Default views fix the whole camera, zoom included, so a view always returns the same scene. Each view aims at the middle of the surface it shows, from the distance at which the brain fills 85% of a 4:3 frame. This is fitted per subject to the surfaces as brainctm packs them — pial-based, with the inflated surface rescaled into the pial box — and cached as default_view_framing.json in the subject's cache/ directory.

Animation panel (camera › create animation)

  • Keyframes on a timeline, with play and scrub.
  • Eight per-keyframe interpolation modes (Bezier by default: smooth, never overshoots a pose you set). They're shared with Python, so make_movie_views renders what the panel previews. (MARK NOTE: I'm not 100% sure these are working properly)
  • render animation builds the movie in the browser and downloads it as one file, like Save image:
    • PNG frames (.zip) — lossless and transparent. These are the frames that match make_png.
    • MP4 video — H.264 through the browser's WebCodecs encoder. Available only in secure contexts (localhost, https, file://); sizes the encoder can't handle are refused up front.
    • Works in static exports too. The server writes nothing.

Flat view matches quickflat on request

  • The flat view frames the flatmap the way quickflat frames its image: centred, and as large as fits. Rendered at the subject's quickflat size, the frame matches make_png's PNG (mask overlap > 0.97 in tests).
  • This is opt-in outside the menu: JSMixer.fit_flat_view() from Python, or match quickflat size in the render form (unticked by default), which also re-frames existing flat keyframes. getImage re-frames a fitted flat view for the image size it writes.

Behaviour fixes

  • Flattening no longer spins or displaces the brain. A flat pose as a keyframe does not record azimuth or altitude, since the controls discard them once flat. The folded and flat camera targets are stored separately (camera.target / camera.flat_target), so an animation into or out of the flat view no longer overwrites the folded pose. The flat target now starts at the real flatmap centre instead of a hardcoded y = -60.
  • Interpolation: each property is interpolated across the keyframes that carry it, in both the JS and Python implementations and in the legacy easings, which previously raised KeyError on a missing key.
  • Axes3D.getImage no longer leaks a WebGL render target per call (a few MB per rendered frame).
  • make_static(..., html_embed=False) no longer fails writing Tornado's bytes to a text-mode file (a latent bug since 4906f5f).

API additions

  • JSMixer: retrieve_new_views(), save_new_views(), fit_flat_view().
  • cortex.export.save_views: default_subject_views(has_flatmap, subject=None), default_view_framing(subject), camera_basis(), FLAT_INERT_PROPS, and the framing constants.
  • cortex.webgl.interpolation (Python twin of resources/js/interpolation.js).
  • cortex.export.headless_viewer(..., download_dir=None), plus downloads / wait_for_download() for capturing browser downloads in tests.
  • New dependency-free JS modules: interpolation.js, viewtools.js, zipstore.js (stored-ZIP writer), mp4mux.js (single-track H.264 MP4 muxer). No third-party code is vendored.

Compatibility

  • save_3d_views output is unchanged. The visual-regression suite passes against the existing references, and regenerating all 28 in place reproduced them byte for byte.
  • Flat views saved before camera.flat_target existed still load as before: camera.target on a flat view is read as the flat target.
  • default_subject_views() called without a subject returns what it did before.
  • Intended change: clicking a default view now resets the zoom as well as the angle.

Testing

  • Full suite locally: 322 passed, 6 skipped, 3 xfailed, 0 failed (headless Chromium 151, SwiftShader).
  • New coverage includes:
    • JS/Python interpolation agreement, including a flat keyframe mid-animation
    • flat framing checked against real make_png output, end to end through the panel's render
    • the downloaded zip (CRCs, entries, transparency) and MP4 (box structure, sample table, and playback in a real <video> element)
    • the folded target surviving a fold → flat → unfold animation
    • default-view framing checked against brainctm's own packing, cache invalidation, and a real 4:3 render's margins

Notes for reviewers

  • main has moved since this branch forked. A trial merge against the last-fetched main is clean (four files are touched on both sides, none conflicting), but main has had more commits since, so GitHub's mergeability check is the one to trust. main includes Fix shader problem that broke 2-D vertex data #715 ("Fix shader problem that broke 2-D vertex data"), and this branch has strict xfails for the Vertex2D shader (Vertex2D objects do not render in webviewer #714), so it's worth re-running the suite after merging main in.
  • The JS and Python interpolators are deliberately twins. A headless test asserts they agree, so a change to one needs the same change in the other.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Anonymized exports expose original subject IDs, and flat-keyframe reframing currently updates the wrong camera target.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

Moves saved-view and animation workflows into the WebGL viewer, with matching Python and browser interpolation and rendering.

Changes:

  • Adds browser view management, animation editing, ZIP/MP4 rendering, and download capture.
  • Adds subject-aware default camera views and quickflat-compatible framing.
  • Adds shared interpolation logic and extensive tests/documentation.
File Description
.gitignore Ignores uv.lock.
docs/​database.rst Documents views, framing, and animations.
cortex/​export/​headless.py Captures browser downloads in headless sessions.
cortex/​export/​save_views.py Adds default views and subject framing.
cortex/​tests/​test_default_views.py Tests default views and framing.
cortex/​tests/​test_interpolation.py Tests interpolation modes and channels.
cortex/​webgl/​interpolation.py Implements Python keyframe interpolation.
cortex/​webgl/​view.py Integrates saved views, framing, and animations.
cortex/​webgl/​template.html Loads new browser modules.
cortex/​webgl/​resources/​css/​mriview.css Styles animation and view panels.
cortex/​webgl/​resources/​js/​axes3d.js Releases rendered WebGL targets.
cortex/​webgl/​resources/​js/​interpolation.js Implements browser interpolation.
cortex/​webgl/​resources/​js/​movement.js Separates folded and flat targets.
cortex/​webgl/​resources/​js/​mp4mux.js Adds H.264 MP4 muxing.
cortex/​webgl/​resources/​js/​mriview.js Adds flat framing and camera controls.
cortex/​webgl/​resources/​js/​mriview_surface.js Computes flat-surface bounds.
cortex/​webgl/​resources/​js/​viewtools.js Implements view and animation UI.
cortex/​webgl/​resources/​js/​zipstore.js Packages PNG frames into ZIP files.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cortex/webgl/view.py
Comment on lines +360 to +364
# Views saved in the filestore, for the "camera > views" menu. Only the
# subjects this viewer displays are read.
my_viewopts["saved_views"] = _load_saved_views(subjects)
my_viewopts["quickflat_size"] = {subj: _quickflat_size(subj)
for subj in subjects}
Comment thread cortex/webgl/resources/js/mriview.js Outdated
// Saved views and the keyframe animation panel (resources/js/viewtools.js).
// These go in sub-folders/buttons rather than into cam_ui.add directly, so
// they do not show up in JSMixer.view_props as capturable properties.
jsplot.viewtools.installCameraUI(this, cam_ui);
Comment on lines +680 to +682
this._el("anim-width").val(this.viewer.imageWidth || 2400);
this._el("anim-height").val(this.viewer.imageHeight || 1200);
this._el("anim-flatmatch").prop("checked", false).on("change", function() {
@mvdoc

mvdoc commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @mvdoc's task in 3m 34s —— View job


Claude's review

Todo list

  • Read repo guidelines (AGENTS.md/CLAUDE.md)
  • Gather diff / changed-file context (network fetch/gh pr diff were blocked by the sandbox in this run, so I read the working tree directly — see note at the bottom)
  • Review cortex/webgl/view.py (make_static, saved views, anonymization)
  • Review cortex/export/save_views.py (default views, framing)
  • Review cortex/webgl/interpolation.py vs. JS twin resources/js/interpolation.js
  • Review resources/js/viewtools.js (views/animation panel)
  • Review resources/js/mriview.js, movement.js, axes3d.js changes
  • Validate the existing Copilot review comments against the current code
  • Post final review feedback

Verified findings from the Copilot review

I re-checked all three of Copilot's findings directly against the current code on this branch — all three are real and still present.

1. make_static(..., anonymize=True) leaks real subject IDs (High) — cortex/webgl/view.py:360-364

my_viewopts["saved_views"] = _load_saved_views(subjects)
my_viewopts["quickflat_size"] = {subj: _quickflat_size(subj) for subj in subjects}

subjects here is the original subject list (package.subjects), while the CTM/SVG files a few lines earlier (view.py:263-297) are renamed through submap to S0, S1, etc. specifically for anonymize=True. Two consequences:

  • The real subject name ends up embedded in viewopts inside the exported (supposedly anonymized) HTML — e.g. saved_views["actual_subject_id"].
  • It also breaks functionality: in the browser, vt.subjects(viewer) (resources/js/viewtools.js:1527 and AnimationPanel.prototype.flatSize, resources/js/viewtools.js:759-767) reads subject names from the live (anonymized) surface menu — i.e. "S0" — so viewopts.quickflat_size["S0"] and viewopts.saved_views["S0"] are both undefined in an anonymized export. The saved-views menu falls back to showing nothing under that subject, and "match quickflat size" silently can't find a size.
  • Fix: build saved_views/quickflat_size keyed by submap.get(subj, subj) (the same remapping already applied to ctms), computed after submap exists. Note this only affects make_static; show() (view.py:565-572) has no anonymize path, so it's unaffected.

2. Unconditional viewtools.installCameraUI call breaks legacy/shadowed templates (Medium) — resources/js/mriview.js:1384

jsplot.viewtools.installCameraUI(this, cam_ui);

This has no guard, unlike the hasInterpolation() pattern the PR itself introduces in viewtools.js for exactly this scenario (a user template dir shadowing template.html per FallbackLoader, per AGENTS.md's documented customization point). If a user's custom template loads an updated mriview.js without the new <script src="resources/js/viewtools.js"> tag, jsplot.viewtools is undefined and this throws during Viewer construction — the entire viewer fails to open, not just the new views/animation panel. Confirmed this is unconditional; should be wrapped in something like if (jsplot.viewtools !== undefined) jsplot.viewtools.installCameraUI(this, cam_ui);.

3. Editing width/height after ticking "match quickflat size" doesn't reframe flat keyframes (Medium) — resources/js/viewtools.js:682 and :826
Only the checkbox's change handler (matchFlatChanged, line 847) calls useFlatSize() + reframeFlatKeyframes(). The anim-width/anim-height inputs have no change binding that re-triggers reframing, and AnimationPanel.prototype.render (line 1299) never calls reframeFlatKeyframes() either — it just plays back whatever's baked into the keyframes via renderSize()/getImage(). So: tick the box (frames get set for e.g. 2400×1200), then type a different size into the fields — the flat keyframes keep the old framing and the final render silently doesn't match quickflat.make_png at the new size, contradicting the documented workflow ("re-frames existing flat keyframes"). Fix: bind change on both size fields to call matchFlatChanged() (or directly useFlatSize()/reframeFlatKeyframes()) when matchesFlat() is true.

Other observations (no new bugs found)

  • axes3d.js:352 (renderbuf.dispose()) correctly frees the WebGL render target after getTexture reads it back — matches the stated fix, looks correct.
  • cortex/webgl/interpolation.py:547-561 (shortest_step) and its JS twin (viewtools.js shortestStep) implement the same tie-break rule (fmod/truncating %, >=180/<=-180 branches) — these stayed in sync, which the PR description flags as important.
  • I did not find additional correctness issues in the areas I had time to read closely (save_views.py framing math, movement.js target separation, mriview_surface.js flat bounds) beyond what's already called out above.

Testing note

I could not execute uv sync, pytest, or any network operation (git fetch, gh pr diff) in this environment — all were blocked pending approval with no interactive user available to grant it. This review is based on static reading of the current working tree against the PR description and the existing automated review comments, not a fresh test run. The PR author reports the full suite passing locally (322 passed); I'd rely on CI for confirmation here rather than my own run.

Summary

The three Copilot-flagged issues are all confirmed real and worth fixing before merge — #1 (anonymization leak) is the most important since it defeats the stated purpose of anonymize=True; #2 is a real regression risk for anyone with a custom template; #3 is a narrower workflow-order bug. Everything else I reviewed (interpolation twins, render-target disposal) looks correct.
(branch: webgl-views-animation-gui)

@jwparks jwparks linked an issue Sep 28, 2026 that may be closed by this pull request
@jwparks

jwparks commented Sep 28, 2026

Copy link
Copy Markdown
Contributor
Screenshot 2026-09-28 at 17 41 24

I've tested @marklescroart 's WebGL animation feature. Overall, it works very well. The animation only controls the 3D view of the brain, not the data (brain map) shown on it, so it isn't affected by whichever map is currently displayed. That made it easy to create exactly the same animation for different datasets just by switching between them, and it doesn't conflict with 4D datasets or with the frames of a timeseries. In the future, it might be nice to animate both at once (camera movement together with 4D data changing over time).

I'd like to raise one issue with keyframes, specifically deleting them. Keyframes are very intuitive to use, and the design matches how keyframes work in other 3D animation tools. However, the keyframe markers (yellow dots on the timeline) don't line up exactly with the frames they represent. For example, I made keyframes at frames 0, 10, 20 and 30 and moved the slider to frame 20, but the slider and the yellow dot (for frame 20) were not in the same place.

This makes keyframes hard to delete. To delete the keyframe at frame 20, I naturally tried to click its yellow dot, but clicking the dot doesn't select it. Instead, I had to click frame 20 on the timeline itself, and because of the offset it was hard to land exactly on frame 20; sometimes I clicked frame 19.

I'd suggest making the markers line up exactly with the timeline, and letting users click a marker to jump to that keyframe!

Thanks for this amazing feature!

marklescroart and others added 12 commits September 28, 2026 13:26
Saving views and building animations previously required driving the
viewer from an interactive python session: _capture_view, _set_view and
make_movie_views all live on the JSMixer handle. The views/ folder that
every subject has in the filestore was invisible from the browser, since
db.get_view needs a live viewer handle just to read a JSON file.

This moves the whole loop into the viewer, with python needed only to
pull results back out and to write PNGs to disk.

Saved views
  show() and make_static() read the views/*.json of the subject(s) the
  viewer displays -- `subjects` is the list already used to build the CTM
  packs, so the filestore is never scanned wholesale -- and ship them to
  the browser inside the existing viewopts dict, which needs no template
  plumbing and works in static exports too. Each becomes a button under
  camera > views.

save view
  Captures the current view in javascript, into viewer._new_views. That
  stays in the browser, separate from the views loaded at startup, until
  the new JSMixer.retrieve_new_views() asks for it -- so saving a view in
  the GUI never touches the filestore. The javascript capture mirrors
  _capture_view, discovering properties from the live menu tree and
  keeping the literal {subject} placeholder in the keys, so views
  interchange with the existing python API and with saved view files.

create animation
  A panel with frame/first/last/fps fields, a frame slider marked with a
  yellow dot per keyframe, and add/clear keyframe, play and render.
  Scrubbing, playback and rendering all run through one setFrame ->
  interpolate -> apply path, reusing the viewer's own _animInterp so
  camera.azimuth still takes the short way around. Values that cannot be
  blended (booleans, strings, and the discrete `layers`, which recompiles
  the shaders on assignment) hold their starting value, the same rule
  _get_anim_seq uses.

Rendering posts frames one at a time -- chained, never parallel, since
both the webgl readback and the upload are async -- to a new /movie
endpoint. It is deliberately separate from MixerHandler.post, which pairs
uploads with filenames by queue order that a browser-driven loop cannot
keep in step.

The server binds all interfaces and serves the page unauthenticated, so
the per-session token only keeps unrelated local processes out. The real
containment is the new movie_dir argument to show(): the browser sends a
path relative to a root python chose (cwd by default) and the handler
refuses anything resolving outside it.

setup.py needs no change; its resources/js/*.js and *.css globs already
cover the new files, and htmlembed inlines them generically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps in the saved-views GUI.

save_new_views()
  retrieve_new_views() handed back a dict and then told the caller, in its
  docstring, to json.dump each entry into <filestore>/<subject>/views/ by
  hand -- leaving them to get the path convention and the name checking
  right. cortex.db.save_view cannot be reused for this: it calls
  vw._capture_view() and stores the *current* view, not a dict it is given.

  The new handle method writes each view to the same location the rest of
  pycortex reads views from, so they appear in the camera > views menu of
  every viewer opened for that subject afterwards. Names come from a text
  field in the browser, so each is checked against the same pattern
  MovieHandler uses before anything is written; validating every name and
  destination up front also means a clash partway through cannot leave some
  views stored and others not. Unlike db.save_view it creates the views
  directory, which is otherwise a latent FileNotFoundError for a subject
  imported without one.

  A stored view is no longer "new": promoteNewView moves it out of the
  browser's _new_views and into the views folder, so retrieve_new_views()
  means exactly "not yet on disk" and a second save is a no-op.

Wider view names
  dat.GUI puts a controller's label in a fixed width: 40% column sized for
  a slider in the remaining 60%, which truncated any view name longer than
  about fifteen characters for no reason. The views folder now tags its
  <li class="folder"> from javascript -- dat.GUI gives folders no
  distinguishing class -- so the stylesheet can widen and centre just those
  rows and leave fold/reset/inflate and Save image alone.

  Checked in a browser against the real dat.gui + menu.js: a function
  controller's row is already clickable in full and the div in .c is empty
  for these buttons, so the name can simply take the whole row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commit d49aaec left an unterminated conflict marker pair at lines 905-906
of cortex/tests/test_webgl_headless.py:

    <<<<<<< HEAD
    =======

There is no closing marker. The module does not parse, so pytest cannot
collect any test in it -- not only the saved-views and animation tests
added on this branch, but every pre-existing alpha, opacity and addData
regression test in the file as well.

Remove both lines. The same merge also left two groups numbered "Group
10", so renumber the second one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The animation panel interpolated linearly between the two bracketing
keyframes, so the brain changed direction abruptly at every keyframe.
Add eight per-keyframe interpolation modes, following the way Adobe
describes keyframe interpolation -- a mode names how a keyframe is
entered and how it is left, so the curve between two keyframes depends
on the pair of modes at its ends -- with cubic Hermite added.

A "smoothing" dropdown in the panel sets the mode of the keyframe under
the playhead, or the mode new keyframes will be given. Bezier is the
default: it carries velocity smoothly through the interior keyframes and
its automatic tangents go flat at local extrema, so the camera never
swings past a pose you set.

Smoothing needs the keyframes on both sides of a keyframe to compute its
tangent, so a bracketing pair is no longer enough. Each property becomes
its own one-dimensional channel -- one per component for the vectors --
spanning the whole keyframe list. Booleans, strings and the discrete
`layers` still step; camera.azimuth is unwrapped so a spin takes the
short way around.

The same eight modes are available from python, so a movie rendered with
make_movie_views matches the preview played in the browser. That means
two implementations of the same arithmetic, in cortex/webgl/
interpolation.py and resources/js/interpolation.js; they use identical
closed forms and a headless test asserts they agree numerically.
_get_anim_seq keeps its existing pairwise path verbatim for the
whole-animation easings ('linear', 'smoothstep', 'smootherstep'), which
have no per-keyframe equivalent, and only takes the new path when a mode
is named. Frame times are generated the same way in both, so frame
counts do not move.

Four deliberate deviations from the reference implementation, each
commented at the site:

* Control-point offsets are signed. The reference takes an unsigned
  square root for the value offset, putting the handles of a keyframe
  with a negative derivative off its own tangent line and breaking the
  C1 continuity the scheme exists to provide.
* The Bezier parameter is solved for rather than assumed equal to
  normalized time. The reference returns early, leaving its own
  root-finding loop unreachable; both coordinates of a cubic Bezier are
  cubic in the parameter, so the two agree only when the handles happen
  to be evenly spaced in time.
* A Linear keyframe followed by one entered smoothly produces a Hermite
  segment entered along the chord. The reference enumerates no segment
  for that combination, desynchronizing its segment and end-time lists.
* A lone keyframe yields a constant interpolator instead of a segment
  list that is built and then discarded.

One subtlety worth recording: half a turn is equally short either way,
and Viewer._animInterp breaks that tie by travelling against the sign of
the raw difference. A symmetric shortest-angle formula reverses one of
the two cases, which would flip a 180-degree spin that used to work, so
shortest_step reproduces the existing tie-break and is tested against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A freshly imported subject had nothing under camera > views until someone
saved something there. Offer nine standard views for every subject
instead: dorsal, ventral, lateral_left and lateral_right on the fiducial
surface, the same four inflated, and flat.

They are assembled from the tables save_views already keeps for
save_3d_views rather than spelled out again, so the camera conventions
stay in one place. dorsal/ventral/lateral_left/lateral_right are the
existing top/bottom/left/right angles under anatomical names, which is
worth stating precisely: the viewer puts the camera at
radius * (sin(alt)cos(azi+90), sin(alt)sin(azi+90), cos(alt)) with up
fixed at +z, and surfaces are in surface RAS, so azimuth 90 sits left of
the brain and 270 right of it, altitude 0 above and 180 below. Because
lookAt resolves the degenerate straight-up and straight-down cases
through the azimuth, anterior lands at the top of the image at azimuth
180 seen from above and at azimuth 0 seen from below -- which is what
"frontal lobe pointed up" needs. test_default_views.py reproduces that
arithmetic and asserts each name against it, so the claim is checked
rather than just documented.

A view stored in the subject's filestore views/ directory under one of
these names replaces that default, for that subject only, leaving the
other eight in place. Saving over a default now also takes effect
immediately: the views menu looks up what a button applies when it is
clicked instead of capturing it, so a re-saved name no longer keeps
applying the old view until the page is reloaded.

Without a flat surface there is no flat view to offer, and the inflated
views sit at full inflation rather than half -- the same correction
save_3d_views makes.

The flat view uses the viewer's established flatmap preset. It cannot
match quickflat.make_figure exactly, since the viewer draws the flat
surface through a 45-degree perspective camera while quickflat
rasterizes it orthographically. What can be matched is the pixel size:
make_png writes the flatmap image itself, whose width follows from the
subject's flat surface bounding box, so _quickflat_size works that out
with quickflat's own arithmetic and ships it in viewopts. The animation
panel shows it in the render form once an animation actually reaches the
flat surface, which is the only time matching it means anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…angle

Two things about the flat view, plus the five test failures this branch
was carrying.

Rendering a flat keyframe did not produce the image quickflat.make_png
writes: the flatmap came out small and low in the frame. Nothing ever
framed it. The camera target stayed wherever it was -- the flat surface
sits some sixty units below the origin, and movement.js compensates with
a hardcoded _flattarget.y of -60 that happens to land within 0.1 of S1's
true centre and would be wrong for any other subject -- and nothing set
the camera distance at all.

quickflat has no camera: it maps the flat surface's bounding box onto
the bounds of the image. The viewer can reproduce that exactly, because
a plane square-on to a perspective camera projects as a uniform scaling.
Surface.flatBBox works out where the flatmap actually is (the flat morph
target lives in the mixSurfs1 attribute, so the geometry's own bounding
boxes describe the fiducial surface and say nothing about it), and
Viewer.flatFraming turns that into a target and a radius for a frame of
a given shape: filling it exactly at the flatmap's own aspect ratio,
which is what _quickflat_size reports, and fitting inside any other
shape rather than cropping to it. Rendered at that size the frame is
make_png's png, same position and same scale, to a mask overlap of 0.99.

The framing is applied on request, never behind a caller's back. The
views menu applies it, JSMixer.fit_flat_view applies it from python, and
getImage re-frames what was framed for the image it is about to write,
since that need not have the shape of the window. _set_view does not, so
save_3d_views and anything else driving the viewer renders exactly what
it always did -- the stored visual regression references are untouched,
which is the evidence for that claim. The animation panel offers the
framing behind a "match quickflat size" box, unticked, which fills in
the size and frames flat keyframes for it, including any already down.

The second thing is the transition into that view. The controls pin the
camera square-on to a flat surface and discard whatever azimuth and
altitude they are given (setAzimuth/setAltitude in movement.js), unless
the surface is tilt-enabled -- but below full unfolding those same
setters write the *folded* angle. So a flat view carrying azimuth 180
gave an animation something to interpolate towards on the way in: the
brain spun as it flattened, fighting the blend setMix performs over the
same quantity, and the folded angle it would return to was overwritten
on the way out. A flat pose now carries neither property, in the default
views, in angle_view_params, and in both capture paths.

That leaves the interpolators needing a rule for a property only some
keyframes carry. Each channel is now built from the keyframes that do
carry it -- in viewtools.js, in interpolation.py, and in the legacy
easings of _get_anim_seq, which raised KeyError on a missing key -- so a
property every keyframe carries behaves as it always did, one carried by
a single keyframe is constant, and a flat keyframe is simply not a knot
on the angle's curve. Folded to flat holds the angle; folded to flat to
folded sweeps smoothly across the whole span.

The five failures:

Two tests read handle.send()'s reply as a value rather than the
one-entry-per-client list it is. _js_run unwraps it, the way _js_attrs
already did for query.

save_new_views refused a view name starting with an underscore, which
the filestore itself accepts -- test_saved_views_are_loaded_into_the_viewer
writes one straight to disk and the viewer loads it. Only a leading '.',
which is what makes '..' possible, is kept out of the first position now.

make_static(html_embed=False) wrote tornado's bytes to a text-mode file.
Latent since 4906f5f; the two static-viewer tests added on this branch
are the first callers to exercise that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The animation panel's render button used to POST every frame to a /movie
endpoint, which wrote it under show()'s movie_dir on the machine serving
the viewer. That was a file-write primitive on the server -- guarded by a
token and a path check, on a server that binds every interface
unauthenticated -- and it put the frames on the wrong machine whenever
the browser was not running where python was.

Now the movie is built in the page and handed over as one download, the
way the viewer's "Save image" button works: it lands wherever that
browser saves downloads, and the server writes nothing. One download per
frame is not an option, since browsers throttle or prompt for hundreds of
them, so the render form offers two single-file formats.

PNG frames (.zip): one lossless, transparent PNG per frame -- the frames
to use when they must match quickflat.make_png. zipstore.js writes stored
entries, since PNG is already compressed, which leaves only a CRC-32 per
frame to compute; the frames stay as Blobs and the archive is a Blob of
parts, so the image bytes are never copied into one buffer. Classic ZIP
only, with a clear message past 65,535 frames or 4 GiB.

MP4 video: H.264, encoded by the browser's own WebCodecs VideoEncoder and
packed by mp4mux.js, a small muxer for one constant-frame-rate track --
ftyp, moov, then mdat, so the file can play before it has loaded.
WebCodecs exists only in secure contexts (localhost, https, file://), so
the option is disabled elsewhere; the encoder's size limit is checked
before the first frame renders; and since H.264 has no alpha channel and
needs even dimensions, frames are laid on black and an odd size gets a
row or column of padding.

With no server involved, rendering also works in make_static viewers.
movie_dir, MovieHandler, the /movie route and viewopts.movie_post are
gone; they were added on this branch and never released, so there is
nothing to deprecate.

Axes3D.getImage allocated a WebGLRenderTarget on every call and never
freed it -- a few megabytes of GPU memory per rendered frame. It now
disposes of it once the pixels are read back, which Save image and
save_3d_views benefit from too; the visual regression references pass
unchanged.

The headless harness now captures downloads (headless_viewer's
download_dir, wait_for_download), so the tests check the files a person
would actually get: every CRC in the zip, the MP4's box structure and
sample table, and that a <video> element plays it back at the right size
and duration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
camera.target -- the point the camera orbits and looks at, a hidden menu
entry -- stood for two things. LandscapeControls keeps a folded target
and a flat target, and shows their blend by how flat the surface is; a
write to camera.target went to whichever matched the unfold state at the
moment it was made.

That was harmless for a view applied all at once, but not for an
animation. Between a folded keyframe and a flat one every frame is partly
unfolded, so each interpolated target was written into the folded copy:
the brain slid down ahead of the flattening -- 88% of the way at frame 20
of 30 -- and after unfolding again it sat some 53 units below where it
started. The same class of bug as the camera angle, fixed earlier on this
branch, for position.

camera.target now always means the folded target, and a new hidden
camera.flat_target always means the flat one (setFoldedTarget and
setFlatTarget in movement.js). Views and keyframes carry both, each is
interpolated on its own and written only to its own copy, and the
transition is simply the blend setMix already performs. Panning still
goes through setTarget, so dragging behaves as it did.

A flat view saved before camera.flat_target existed stores its flat
target as camera.target, since that is where it went once the surface
was flat, so a flat view carrying camera.target and no
camera.flat_target is read that way -- in vt.applyView and in
JSMixer._set_view alike. save_3d_views depends on that reading for the
target it passes with its flatmap: regenerating every visual regression
reference in place leaves all 28 byte-identical.

The flat target also no longer starts at a hardcoded y = -60, a guess
within 0.1 of S1's flatmap centre and wrong for any other subject. It
starts at the flatmap's real centre, measured when the viewer loads. The
surface is folded then, and flatBBox measures through the current
transforms, so Surface.flatViewBBox poses the pivot groups the way the
flat view would -- flat, pivoted 180, unshifted -- measures, and puts
them back before anything is drawn. It agrees with the fitted flat view
to the digits shown. The built-in flat view accordingly names no target,
and setting it from python lands the flatmap centred rather than sixty
units low.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The default views named camera angles and an unfold amount, but no
camera radius, so clicking one kept whatever zoom the viewer happened to
have -- a view could not reliably return the same scene, and so could
not be relied on to render the same images twice. They also all aimed at
the origin, while a brain need not be centred there: S1's is 22 mm
anterior and 11 mm superior of it, which left it off-centre in every
view.

Every default view but flat now carries a camera target and radius. The
target is the middle of the surface the view shows; the radius is the
smallest distance at which every vertex of that surface falls inside 85%
of a 4:3 frame, worked out through the viewer's own perspective camera
(camera_basis, moved here from the tests so the fit and the anatomical
name checks share one model of it). A wider window just leaves more room
either side. flat keeps the framing it fits to the window itself.

No constant could do this for every subject -- brains differ in size,
pycortex serves macaque data as well as human, and an inflated surface
is a different shape again -- so the framing is fitted to each subject's
own surfaces, as the viewer lays them out. That is not the surface files
as they stand: brainctm packs a subject on its pial surface, with the
white matter alongside and the folded brain drawn at their midpoint, and
rescales the inflated surface one hemisphere at a time into the pial
bounding box. Fitting the raw inflated file left the inflated views at
about half size; _viewer_points reproduces the packing, and a test checks
it vertex for vertex against brainctm's own output.

Reading the surfaces takes about half a second, so the fit is cached as
default_view_framing.json in the subject's cache directory, and refitted
when a surface file or a framing constant changes. A filestore that
cannot be written only loses the caching, and surfaces that cannot be
read leave the views as they were rather than breaking a viewer.

default_subject_views gains an optional subject and is otherwise
unchanged; save_3d_views, which builds its views from the shared tables,
is untouched, and the visual regression references pass as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The animation panel's keyframe dots did not sit under the slider's thumb
when the playhead was on their frame. Measured in headless Chrome, with
keyframes at frames 0, 10 and 30 of 30, they sat 4 px left of the thumb
at the start and 3 px right of it at the end -- more than a dot's width.

The dots were inset by a hardcoded 7 px, a guess at half the browser's
own thumb. A thumb's centre travels from half a thumb in at the first
frame to half a thumb short of the end at the last, but the native thumb
differs in size and inner padding from one browser and platform to the
next (about 20 px here), so no fixed inset suits every browser. The
slider now draws its own 12 px thumb and track, and a single
--anim-thumb custom property sizes both the thumb and the dots' inset,
so the two cannot drift apart. Restyling the slider also exposed w2ui's
rule for every input -- a border and padding the native slider ignored,
and whose 1 px border would have moved the thumb in from the ends -- so
that is undone for it. The offsets are now 0 px at every keyframe.

The dots are also clickable: a click stops playback and puts the
playhead on that keyframe through the same path as typing a frame
number (AnimationPanel.goToFrame, which both now use), so the slider,
the frame field and the smoothing dropdown all follow. Each dot takes
clicks in a 12 px area around its 6 px mark, laid out in the band below
the slider so it never covers it; the dots' container still passes
everything else through.

The tests find the thumb and the dots by colour in a screenshot of the
slider, click the dots, and drag the slider over them. That needs the
page itself, which sync Playwright only lets its own thread touch, so
the headless harness gains _PlaywrightThread.run_on_page, which runs a
function on the worker thread and returns what it returns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nges

Three issues from review of this branch.

Anonymized exports leaked the real subject IDs. make_static(anonymize=True)
renames subjects S0, S1, ... in the surface files and the dataset
metadata, but the saved views and the quickflat size hint added on this
branch were still keyed by the real IDs -- putting them back into the
page, and leaving the viewer unable to find either entry, since it looks
them up under the anonymized surface names (so an anonymized export's
views menu came out empty). They are now keyed by the page's own names.
Doing that exposed an older inconsistency: make_static numbered the
anonymized names once in the order of a set, which changes from one
process to the next, and once in sorted order, so a multi-subject export
could give one subject two names. It now builds one sorted mapping and
uses it for the files, the metadata and the viewer options alike; a
single-subject export's names are unchanged.

A custom template.html written before viewtools.js existed stopped the
viewer from opening at all: mriview.js called
jsplot.viewtools.installCameraUI unconditionally. The call is now
guarded, so such a viewer opens without the views menu and the animation
panel, and rendering reports a missing zipstore.js or mp4mux.js in the
panel's status line instead of throwing.

With "match quickflat size" ticked, changing the render size did not
re-frame the flat keyframes already laid down, so a render at a size
typed after ticking the box used framing meant for the previous shape
of frame. Typed sizes now re-frame them, as does setRenderSize, which
sets the fields from code without firing their change events; and
render() re-frames first, so a size that reached the fields any other way
is still honoured.

Each fix has a test that fails without it: an anonymized export whose
subjects and viewer options must name the same subjects, a shadowing
template with the new script tags removed, and the size set from code,
typed, and changed with no event at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@marklescroart
marklescroart force-pushed the webgl-views-animation-gui branch from 0a15e9e to 4bfe68d Compare October 1, 2026 06:09
@marklescroart

Copy link
Copy Markdown
Contributor Author

Latest commits should rebase and address all above concerns. Waiting for tests to run on git (all tests ran locally and passed), then will squash and merge.

@kroq-gar78

Copy link
Copy Markdown
Contributor

You'll probably need to add trough to the codespell ignore list to fix the codespell error: https://github.com/gallantlab/pycortex/blob/main/pyproject.toml#L55

Also, I am going to merge the bumpy flatmap refactor now. It will introduce a small merge conflict in cortex/tests/test_webgl_headless.py (just add the animation tests to the bottom).

@kroq-gar78 kroq-gar78 changed the title Webgl views animation gui NF webgl views animation gui Oct 1, 2026
@marklescroart

Copy link
Copy Markdown
Contributor Author

Errrr don't know how to add words to codespell, will look into it, but why does it hate trough? That's a real English word, legitimately used. These will take me a min to reconcile, I'm teaching today and have meetings.

@alexhuth

alexhuth commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

trough

lmao dumb bot

marklescroart and others added 2 commits October 1, 2026 11:58
Brings in #695 (unified NaN and alpha handling across quickflat, WebGL
and RGB dataviews), #755 (setup-uv bump) and #720 (bumpy flatmap
redesign). Merged rather than rebased: the branch has an open, reviewed
pull request, and pycortex squash-merges pull requests, so a merge
commit here never reaches main's history while a rebase would mean
another force-push under the review.

One conflict, in cortex/tests/test_webgl_headless.py, where both lines
of work appended tests at the end of the file. Both are kept: main's
"Group 6: Bumpy flatmap", then this branch's sections. No names collide.

The rest merged cleanly as text, including the files both sides
changed: mriview.js, mriview_surface.js and docs/database.rst. #720
reworks how the bumpy flatmap's relief is built and adds surface-info
attributes to the surface packs, but leaves alone what this branch's
flat framing and default-view framing rely on: _makeFlat, flatoff, the
pivot groups, and brainctm's renormalized packing. Checked rather than
assumed: the quickflat-match tests, the vertex-for-vertex check against
brainctm and main's own bumpy-flatmap test all pass on the merged tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"a peak and a trough" describes the test curve correctly, but codespell
flags "trough" as a misspelling of "through" and fails the spelling
check. Reworded to "a peak and a dip" rather than adding "trough" to the
repo-wide ignore list, which would stop codespell catching the far more
common case: "trough" written for "through" in prose.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@marklescroart

Copy link
Copy Markdown
Contributor Author

Changed 'trough' to 'dip' (eff off codespell), dealt with conflicts with main, should be ready to merge once tests run.

@kroq-gar78

kroq-gar78 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

I'll increase the test timeout to 40 mins and re-run it. (It looks like this branch adds several browser-based tests, so no surprise that we need more time.)

@kroq-gar78
kroq-gar78 merged commit 9d2e471 into main Oct 1, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add webgl-based animation creation

6 participants