Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: save additional mime bundles in notebook #3107

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

maartenbreddels
Copy link
Member

@maartenbreddels maartenbreddels commented Feb 9, 2021

This is a proof of concept for allowing embedding of additional entries in the mime bundle, such as static images, svg, video etc from the front-end.

The basic idea is to have an API Widget.generateMimeBundle which will generate additional mime entries, which can be overridden in widget subclasses. Bqplot can inject svg, core ipywidgets can inject HTML for the HTML widget, an image for the Image widget etc.

TODO:

  • Get approval/blessing for this approach in general.
  • See if we can match the cell output to a particular view, and as the view for a mime bundle, because the output may be specific to the view (such as in ipyvolume and ipywebrtc, otherwise fallback to the model)
  • Implement this in jupyter lab
  • Remove the dummy text/html mime entry.
  • Implement this for a few widgets (Image, HTML)

This PR differs from #2282 in just having an API for providing the API, instead of specific methods for particular representation (although the two PR's can be seen as orthogonal).

Related: #314 #2280 #2282 higlass/higlass-python#49 glue-viz/glue-jupyter#207

@maartenbreddels
Copy link
Member Author

As a concrete example:

import ipywidgets as widgets
widgets.Image.from_url('http://localhost:8891/static/base/images/logo.png')

Which will end up in the notebook JSON file as:

...
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "09f05d25048e449bba162bf1f25c023e",
       "version_major": 2,
       "version_minor": 0
      },
      "text/html": [
       "<h1>Hi!</h1>"
      ],
      "text/plain": [
       "Image(value=b'http://localhost:8891/static/base/images...', format='url')"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "widgets.Image.from_url('http://localhost:8891/static/base/images/logo.png')"
   ]
  },
...

@maartenbreddels
Copy link
Member Author

Of course, matplotlib/ipympl#16 and matplotlib/ipympl#294 and matplotlib/ipympl#150 are related as well, and big motivation for this from @martinRenou 's side I think.

return Promise.resolve({
'application/vnd.jupyter.widget-view+json': {
model_id: this.model_id,
version_major: 2,
Copy link
Member

Choose a reason for hiding this comment

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

Should we take the version from what the model defines instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

I forgot to mention that this should be removed, actually

Copy link
Member

Choose a reason for hiding this comment

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

Oh ok :) 👍🏼

this.notebook.events.on('before_save.Notebook', async () => {
var cells = Jupyter.notebook.get_cells();
// notebook.js save_notebook doesn't want for this promise, we are simply lucky when this
// finishes before saving.
Copy link
Member

Choose a reason for hiding this comment

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

This seems very weak :S

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I think this means it will be a lab feature only

Copy link
Member

Choose a reason for hiding this comment

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

What about not making it a Promise?

Copy link
Member Author

Choose a reason for hiding this comment

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

we to await the model, and making `generateMimeBundle async also makes sense, since it will involve async cases in bqplot at least.

@martinRenou
Copy link
Member

Thanks a lot @maartenbreddels for trying this. I will give it a try.

@maartenbreddels
Copy link
Member Author

I cleaned it up a bit, and implemented this for HTML and Image.

For instance, GitHub renders the HTML:
https://gist.github.com/maartenbreddels/695c2eac56d8ff2114998e69f93296d9

@maartenbreddels
Copy link
Member Author

Bad news, it seems we cannot do this in Lab either. You can connect to the save event, like we do with saving the widget state:

    context.saveState.connect((sender, saveState) => {
      if (saveState === 'started' && settings.saveState) {
        this._saveState();
      }
    });

But again, this is sync only https://github.com/jupyterlab/jupyterlab/blob/b4db7f03d2ad0b91a9c8c252c56ae5e4a9408fbb/packages/docregistry/src/context.ts#L506

Maybe we can make it sync only for now, and it will work in classic and lab.

@willingc
Copy link
Contributor

willingc commented Feb 9, 2021

@MSeal fyi

@MSeal
Copy link

MSeal commented Feb 9, 2021

Adding support for this would be quite helpful!

It would also be really nice if we made this work across UIs (remember there's a dozen of so proprietary UIs as well). Working in classic, lab, and maybe nteract -- I think those are the 3 most popular open source UIs -- would be ideal to ensure it works in other environments. In lieu of that support, we should at least consider documenting changes needed by a UI that partially or completely supports widgets currently. e.g. 8.0 Widget UI Interaction changes section in the changelog?

@ianhi
Copy link
Contributor

ianhi commented Feb 9, 2021

This will also require changes on all the frontends? My understanding from conversation on gitter awhile ago https://gitter.im/jupyter-widgets/Lobby?at=5f0ea2e30d37916fda770757

is that the frontend chooses the precedence in the mimebundle and currently lab et al always give the widget mimetype higher precedence.

@MSeal
Copy link

MSeal commented Feb 10, 2021

is that the frontend chooses the precedence in the mimebundle and currently lab et al always give the widget mimetype higher precedence.

It seemed from comments that UIs would need to adjust their rendering code some to look for mimetypes within the bundle and that saving those mimetypes in the bundle had UI specific limitations.

@maartenbreddels
Copy link
Member Author

frontend chooses the precedence in the mimebundle and currently lab et al always give the widget mimetype higher precedence.

How lab/classic/nbconvert uses the mime bundle is something we can disagree about. I think we should have a dropdown to choose/override this default, in lab, classic and nbconvert, but we don't need to address that now I think.

The main point of this PR is to have an API to ask for an extra mime bundle, an example use case (HTML and Image widget), and an implementation example for Lab and/or classic notebook on how to use that API (inject it on save).

On that last point, it seems technically not possible, for classic and lab jupyterlab/jupyterlab#9764 if generating the mime bundle needs to be async.

@ianhi
Copy link
Contributor

ianhi commented Feb 10, 2021

frontend chooses the precedence in the mimebundle and currently lab et al always give the widget mimetype higher precedence.

How lab/classic/nbconvert uses the mime bundle is something we can disagree about. I think we should have a dropdown to choose/override this default, in lab, classic and nbconvert, but we don't need to address that now I think.

I think I agree with you? I was/am just concerned is that ipywidgets doesn't have the ability to do that overriding. So this may require work outside of ipywidgets.

The main point of this PR is to have an API to ask for an extra mime bundle

👍

@martinRenou
Copy link
Member

How would this play with #3114?

@maartenbreddels
Copy link
Member Author

They should play nice together and are independent (although I'd like both of them in 8.0), I'm working on this branch now, and I'm reusing some of the code from there to make it possible to do this in a sync way.
Both methods simply listen to the same save event.

// finishes before saving.
await Promise.all(
cells.map(async cell => {
var widget_output = cell.output_area.outputs.find(output => {
Copy link
Member

Choose a reason for hiding this comment

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

I am getting the following error from this line:

manager.js:228 Uncaught (in promise) TypeError: Cannot read property 'outputs' of undefined
    at manager.js:228
    at Array.map (<anonymous>)
    at window._Events.<anonymous> (manager.js:227)
    at window._Events.dispatch (jquery.min.js:2)
    at window._Events.v.handle (jquery.min.js:2)
    at Object.trigger (jquery.min.js:2)
    at window._Events.<anonymous> (jquery.min.js:2)
    at Function.each (jquery.min.js:2)
    at S.fn.init.each (jquery.min.js:2)
    at S.fn.init.trigger (jquery.min.js:2)
(anonymous) @ manager.js:228
(anonymous) @ manager.js:227
dispatch @ jquery.min.js:2
v.handle @ jquery.min.js:2
trigger @ jquery.min.js:2
(anonymous) @ jquery.min.js:2
each @ jquery.min.js:2
each @ jquery.min.js:2
trigger @ jquery.min.js:2
events.trigger @ events.js:31
Notebook.save_notebook @ notebook.js:2735
Notebook.save_checkpoint @ notebook.js:3333
handler @ actions.js:885
final_actions.<computed>.handler @ actions.js:934
ActionHandler.call @ actions.js:1019
ShortcutManager.call_handler @ keyboard.js:554
KeyboardManager.handle_keydown @ keyboardmanager.js:215
(anonymous) @ keyboardmanager.js:181
dispatch @ jquery.min.js:2
v.handle @ jquery.min.js:2
async function (async)
(anonymous) @ manager.js:226
dispatch @ jquery.min.js:2
v.handle @ jquery.min.js:2
trigger @ jquery.min.js:2
(anonymous) @ jquery.min.js:2
each @ jquery.min.js:2
each @ jquery.min.js:2
trigger @ jquery.min.js:2
events.trigger @ events.js:31
Notebook.save_notebook @ notebook.js:2735
Notebook.save_checkpoint @ notebook.js:3333
handler @ actions.js:885
final_actions.<computed>.handler @ actions.js:934
ActionHandler.call @ actions.js:1019
ShortcutManager.call_handler @ keyboard.js:554
KeyboardManager.handle_keydown @ keyboardmanager.js:215
(anonymous) @ keyboardmanager.js:181
dispatch @ jquery.min.js:2
v.handle @ jquery.min.js:2

Copy link
Member

Choose a reason for hiding this comment

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

Not sure it's normal that cell.output_area is undefined. We might want to add a check for undefined before this line?

Copy link
Member

Choose a reason for hiding this comment

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

I suppose you tested with only one cell containing an output. When there is no output under one cell in the Notebook, cell.output_area seems to be undefined.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, that should be fixed now

@martinRenou
Copy link
Member

martinRenou commented Feb 11, 2021

Tried with ipympl, I saved the image doing:

generateMimeBundle() {
    return Promise.resolve({
        'text/html': `<img src="${this.offscreen_canvas.toDataURL('image/jpeg', 1.0)}"
                        width="${this.offscreen_canvas.width}" height="${this.offscreen_canvas.height}"/>`
    });
}

It seems to work! But when I reopen the saved Notebook I see the image for ~half a second, then it gets replaced with the following text:
save1

My understanding is that there is still the widget repr that tries to render but fails because the model state does not exist. It would be nice to remove the widget repr in the case of ipympl, would this be possible?

@maartenbreddels
Copy link
Member Author

It seems to work! But when I reopen the saved Notebook I see the image for ~half a second, then it gets replaced with the following text:

Yes, I think that is still expected behaviour / separate issue. I think we should have a dropdown to select which mime entry to show.

@maartenbreddels
Copy link
Member Author

I've done a refactor of this.

We now ask the view to generate the mimebundle. Jupyter notebook will call on notebook saving, the generateMimeBundle for each view, so this function should not be too expensive, and cannot be async.

A new action (and menu item) "'Add extra to output cell mime bundle'" is added, which will call generateMimeBundleExtra , which can be async, and be more expensive since it will not be called on each save operation. I think bqplot should use this to inject the svg/png into the mime bundle, video widgets could embed the video, and a possible future Screeenshot widget can use html2canvas to inject a screenshot of it's child widget into its mime bundle.

This approach makes it such that injection of mime bundles for common cases can be done sync, and thus be done on save in classic notebook and jupyter lab, while at the same moment allow for more expensive and async generation of mime bundles on user request.

@martinRenou
Copy link
Member

martinRenou commented Feb 12, 2021

I think we should have a dropdown to choose/override this default, in lab, classic and nbconvert, but we don't need to address that now I think.

We should maybe brainstorm a bit about this. IMO a dropdown would not be a great UX.

Edit: If we go for the dropdown idea, maybe we should provide a way for custom widget libraries to provide a sensible default repr.

@maartenbreddels
Copy link
Member Author

It's good to consider a few ideas on how to build on top, but indeed a dropdown is not the best UX, we could take a look at https://zeppelin.apache.org/docs/0.8.0/usage/display_system/basic.html for inspiration, but for sure a separate issue/PR.

Let's first see if we can agree on populating the mime bundle in a user-friendly way. What do you think of the latest idea (auto save cheap mime bundles, expensive ones on user request)?

@maartenbreddels maartenbreddels marked this pull request as ready for review February 12, 2021 09:27
Copy link
Member

@martinRenou martinRenou left a comment

Choose a reason for hiding this comment

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

Yeah, I think having two approaches like that is good :)

I also agree it's better to have it in the view.

/**
* Register a widget model.
*/
register_model(model_id: string, modelPromise: Promise<WidgetModel>): void {
Copy link
Member

Choose a reason for hiding this comment

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

Was it intended to remove those two methods?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, they went up a base class (base manager), because we need to same feature for the notebook manager.

packages/controls/src/widget_image.ts Outdated Show resolved Hide resolved
@martinRenou
Copy link
Member

How confident are you that we could make a JupyterLab implementation? I've tried something very similar here: matplotlib/ipympl#294 but it didn't work for some reason. It seems to me that ipywidgets was overwriting whatever I was putting in the Notebook instance, but I might be wrong.

@maartenbreddels
Copy link
Member Author

I am totally lost in Lab-land, I cannot find an easy way to match the WidgetView to the output area (in classic we have access to the DOM, so we test with element.contains), if you have ideas on that, I can help you with the rest.

@martinRenou
Copy link
Member

Maybe @jtpio would have some ideas?

@martinRenou
Copy link
Member

Rebased

@martinRenou
Copy link
Member

martinRenou commented Sep 13, 2021

@maartenbreddels if you don't mind I'll iterate on this and will try to implement the same for JupyterLab.

@martinRenou
Copy link
Member

I'd also like to add a way for widgets to completely overwrite the mimebundle if they want. This would be useful for ipympl, because it has been asked many times to save a static image of the plot when saving the Notebook, which I believe make sense for ipympl.

But this should not be the default for all widgets.

This will be useful for widgets like ipympl or ipycanvas, where the
model does not contain enough information to recover the widget (the
kernel only contains the information about the state of the plot) so we
need to store a static version of the plot (static image) for
convenience, and we need to dump the widget model.
Comment on lines +94 to +105
async generateMimeBundle() {
return {};
}

/**
* Whether the `generateMimeBundle` output should
* overwrite the mimeBundle or extend it
*/
shouldOverwriteMimeBundle(): boolean {
return false;
}

Copy link
Member Author

Choose a reason for hiding this comment

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

What about generateMimeBundle making the default mimebundle (with the widget in it).
subclases can then do:

return {'text/html': view.el.outerHTM, ... await super.generateMimeBundle()}
// or
return {'text/html': view.el.outerHTM}

depending on if they want to overwrite it. Does that make sense?

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense!

@martinRenou
Copy link
Member

@maartenbreddels this is becoming less critical for ipympl, we're looking into always putting a static version of the plot in matplotlib/ipympl#343

@blois
Copy link
Contributor

blois commented Sep 23, 2021

Rather than a hook on save would it be possible to have an API like widget.set_output_mime_data(mimetype, data, metadata) which would update any display items for that widget with the new data? One benefit of this is that it allows the document to be invalidated (prompt for save). Widgets like ipympl would probably need to debounce this to avoid generating base64 image data too often.

The other side of this is allowing widgets to access this data- ipympl should be able to reach back to the display item to get the image data when re-rendering.

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.

6 participants