Capturing screenshots

While there are a number of additional features we could conceive adding to our Service, there is little more we need to add to have a functional viewport capture capability.

Despite having only provided placeholder functionality in the core of our service, we’ll be able to have image capture capability with little additional changes to the existing code we have implemented so far. This is a valuable advantage, as the care we have taken to follow best practices so far would otherwise have been of little return on investment.

Building on existing features

As you may have already noticed if you had the opportunity to use Omniverse applications before, there is already an option to capture screenshots present in the Edit > Capture Screenshot menu (and also available using the F10 key of the keyboard). In order to reuse this feature which already brings us very close to our intended goal, we will be wrapping this convenient feature under a utility function, which we’ll then only use to call in the function handler for our Service.

This will be a flexible solution for our Service moving forward too, as we can conceive of a number of additional desirable features we would wish to support in the future too, which we could develop in parallel to the version we could already ship to a number of Users for early feedback, before swapping to a newer version.

We could even conceive exposing different image capture implementations in the same Service, with our function handler accepting an optional argument that would select which of our capture features to execute. This would allow us to ship this candidate for an improved version of the Service to a number of Users, and use a feature flag to request their feedback on alternative methods so we could collect their insights about potential stability or performance issues before rolling out the feature to a larger audience in a future version of the Service.

First things first, let’s start by introducing a few utility methods to capture and serve images from a stored location. We will implement them independently from our Service stack, and take care of decoupling them from the message-handling section of our API in order to make it easier to evolve these sections independently of each other. This will not only make testing them easier, this will also promote code reuse across projects. Multiple benefits!

exts/omni.services.example.viewport.capture/omni/services/example/viewport/capture/utils.py
  1import asyncio
  2import os
  3import shutil
  4from typing import Optional, Tuple
  5
  6import carb.settings
  7import carb.tokens
  8
  9import omni.kit.actions.core
 10import omni.kit.app
 11import omni.usd
 12
 13
 14# Let's include a small utility method to facilitate obtaining the name of the extension our code is bundled with.
 15# While we could certainly store and share the `ext_id` provided to the `on_startup()` method of our Extension, this
 16# alternative method of obtaining the name of our extension can also make our code more portable across projects, as it
 17# may allow you to keep your code changes located closer together and not have to spread them up to the main entrypoint
 18# of your extension.
 19def get_extension_name() -> str:
 20    """
 21    Return the name of the Extension where the module is defined.
 22
 23    Args:
 24        None
 25
 26    Returns:
 27        str: The name of the Extension where the module is defined.
 28
 29    """
 30    extension_manager = omni.kit.app.get_app().get_extension_manager()
 31    extension_id = extension_manager.get_extension_id_by_module(__name__)
 32    extension_name = extension_id.split("-")[0]
 33    return extension_name
 34
 35
 36# Building on the utility method just above, this helper method helps us retrieve the path where captured images are
 37# served from the web server, so they can be presented to clients over the network.
 38def get_captured_image_path() -> str:
 39    """
 40    Return the path where the captured images can be retrieved from the server, in the `/{url_prefix}/{capture_path}`
 41    format.
 42
 43    Args:
 44        None
 45
 46    Returns:
 47        str: The path where the captured images can be retrieved from the server.
 48
 49    """
 50    extension_name = get_extension_name()
 51    settings = carb.settings.get_settings()
 52    url_prefix = settings.get_as_string(f"exts/{extension_name}/url_prefix")
 53    capture_path = settings.get_as_string(f"exts/{extension_name}/capture_path")
 54
 55    captured_images_path = f"{url_prefix}{capture_path}"
 56    return captured_images_path
 57
 58
 59# In a similar fashion to the utility method above, this helper method helps us retrieve the path on disk where the
 60# captured images are stored on the server. This makes it possible to map this storage location known to the server to a
 61# publicly-accessible location on the server, from which clients will be able to fetch the captured images once their
 62# web-friendly names have been communicated to clients through our Service's response.
 63def get_captured_image_directory() -> str:
 64    """
 65    Return the location on disk where the captured images will be stored, and from which they will be served by the web
 66    server after being mounted. In order to avoid growing the size of this static folder indefinitely, images will be
 67    stored under the `${temp}` folder of the Omniverse application folder, which is emptied when the application is shut
 68    down.
 69
 70    Args:
 71        None
 72
 73    Returns:
 74        str: The location on disk where the captured images will be stored.
 75
 76    """
 77    extension_name = _get_extension_name()
 78    capture_directory_name = carb.settings.get_settings().get_as_string(f"exts/{extension_name}/capture_directory")
 79    temp_kit_directory = carb.tokens.get_tokens_interface().resolve("${temp}")
 80    captured_stage_images_directory = os.path.join(temp_kit_directory, capture_directory_name)
 81    return captured_stage_images_directory
 82
 83
 84# This is the main utility method of our collection so far. This small helper builds on the existing capability of the
 85# "Edit > Capture Screenshot" feature already available in the menu to capture an image from the Omniverse application
 86# currently running. Upon completion, the captured image is moved to the storage location that is mapped to a
 87# web-accessible path so that clients are able to retrieve the screenshot once they are informed of the image's unique
 88# name when our Service issues its response.
 89async def capture_viewport(usd_stage_path: str) -> Tuple[bool, Optional[str], Optional[str]]:
 90    """
 91    Capture the viewport, by executing the action already registered in the "Edit > Capture Screenshot" menu.
 92
 93    Args:
 94        usd_stage_path (str): Path of the USD stage to open in the application's viewport.
 95
 96    Returns:
 97        Tuple[bool, Optional[str], Optional[str]]: A tuple containing a flag indicating the success of the operation,
 98            the path of the captured image on the web server, along with an optional error message in case of error.
 99
100    """
101    success: bool = omni.usd.get_context().open_stage(usd_stage_path)
102    captured_image_path: Optional[str] = None
103    error_message: Optional[str] = None
104
105    if success:
106        event = asyncio.Event()
107
108        menu_action_success: bool = False
109        capture_screenshot_filepath: Optional[str] = None
110        def callback(success: bool, captured_image_path: str) -> None:
111            nonlocal menu_action_success, capture_screenshot_filepath
112            menu_action_success = success
113            capture_screenshot_filepath = captured_image_path
114
115            event.set()
116
117        omni.kit.actions.core.execute_action("omni.kit.menu.edit", "capture_screenshot", callback)
118        await event.wait()
119        await asyncio.sleep(delay=1.0)
120
121        if menu_action_success:
122            # Move the screenshot to the location from where it can be served over the network:
123            destination_filename = os.path.basename(capture_screenshot_filepath)
124            destination_filepath = os.path.join(get_captured_image_directory(), destination_filename)
125            shutil.move(src=capture_screenshot_filepath, dst=destination_filepath)
126
127            # Record the final location of the captured image, along with the status of the operation:
128            captured_image_path = os.path.join(get_captured_image_path(), destination_filename)
129            success = menu_action_success
130    else:
131        error_message = f"Unable to open stage \"{usd_stage_path}\"."
132
133    return (success, captured_image_path, error_message)

Now that these utility methods are compartmentalized in their own independent module, away from the function-handling features of our Service, all we need to do to connect this live implementation of our feature is to call it from our Service:

exts/omni.services.example.viewport.capture/omni/services/example/viewport/capture/utils.py
# [...]

async def capture(request: ViewportCaptureRequestModel,) -> ViewportCaptureResponseModel:
-    # For now, let's just print incoming request to the log to confirm all components of our extension are properly
-    # wired together:
-    carb.log_warn(f"Received a request to capture an image of \"{request.usd_stage_path}\".")
+    success, captured_image_path, error_message = await capture_viewport(usd_stage_path=request.usd_stage_path)

    return ViewportCaptureResponseModel(
-       success=False,
-       captured_image_path=None,
-       error_message="Image not yet captured.",
+       success=success,
+       captured_image_path=captured_image_path,
+       error_message=error_message,
    )

Hopefully these few lines where we performed a change illustrate the benefits of the planning we performed initially, and the separation of concerns between the various sections of our implementations:

  • The API section of our Service takes care of handling the requests from clients, routes messages to the appropriate delegates, and issues responses back to clients after performing work.

  • The system can otherwise remain unaware of the way client requests or responses are formatted, allowing for flexibility in the way operations are performed. Changing the inner implementation of our operation will have limited impact on clients who may be using our Service. Similarly, Users of our Service can grow confident in the stability of our system, as changes we may be interested in performing over time will have limited impact on the way Users interface with our Service thanks to the fact that it does not leak its implementation details through the API.

Serving content to Users

One last operation we will perform in order to ensure the images we captured can be properly served back to clients through the network is to map a web-friendly storage folder on the server. Let’s update our extension entrypoint to register this mounted storage location, reusing the utility methods we conveniently shared earlier.

We’ve highlighted the changes we performed in this step to make it easier to reference the few features we added:

exts/omni.services.example.viewport.capture/omni/services/example/viewport/capture/extension.py
 1import os
 2
 3from fastapi.staticfiles import StaticFiles
 4
 5import carb
 6
 7import omni.ext
 8from omni.services.core import main
 9
10# As most of the features of our API are implemented by the means of function handlers in the `/services` sub-folder,
11# the main duty of our extension entrypoint is to register our Service's `@router` and dictate when its capability
12# should be enabled or disabled under the guidance of the User or the dependency system.
13from .services.capture import router
14
15# For convenience, let's also reuse the utility methods we already created to handle and format the storage location of
16# the captured images so they can be accessed by clients using the server, once API responses are issued from our
17# Service:
18from .utils import get_captured_image_directory, get_captured_image_path
19
20
21# Any class derived from `omni.ext.IExt` in the top level module (defined in the `python.module` section of the
22# `extension.toml` file) will be instantiated when the extension is enabled, and its `on_startup(ext_id)` method
23# will be called. When disabled or when the application is shut down, its `on_shutdown()` will be called.
24class ViewportCaptureExtension(omni.ext.IExt):
25    """Sample extension illustrating registration of a service."""
26
27    # `ext_id` is the unique identifier of the extension, containing its name and semantic version number. This
28    # identifier can be used in conjunction with the Extension Manager to query for additional information, such
29    # as the extension's location on the filesystem.
30    def on_startup(self, ext_id: str) -> None:
31        ext_name = ext_id.split("-")[0]
32
33        # At this point, we register our Service's `router` under the prefix we gave our API using the settings system,
34        # to facilitate its configuration and to ensure it is unique from all other extensions we may have enabled:
35        url_prefix = carb.settings.get_settings().get_as_string(f"exts/{ext_name}/url_prefix")
36        main.register_router(router=router, prefix=url_prefix, tags=["Viewport capture"],)
37
38        # Proceed to create a temporary directory in the Omniverse application file hierarchy where captured stage
39        # images will be stored, until the application is shut down:
40        captured_stage_images_directory = get_captured_image_directory()
41        if not os.path.exists(captured_stage_images_directory):
42            os.makedirs(captured_stage_images_directory)
43
44        # Register this location as a mount, so its content is served by the web server bundled with the Omniverse
45        # application instance, thus making the captured image available on the network:
46        main.register_mount(
47            path=get_captured_image_path(),
48            app=StaticFiles(directory=captured_stage_images_directory, html=True),
49            name="captured-stage-images",
50        )
51
52    def on_shutdown(self) -> None:
53        # When disabling the extension or shutting down the instance of the Omniverse application, let's make sure we
54        # also deregister our Service's `router` in order to avoid our API being erroneously advertised as present as
55        # part of the OpenAPI specification despite our handler function no longer being available:
56        main.deregister_router(router=router)
57        main.deregister_mount(path=get_captured_image_path())

Note

For additional information regarding how to execute this Service via code, consult the online documentation about the using the asynchronous request client.

Use cases for such use cases include:
  • Executing this Omniverse Service from another Service, either on the same machine or from a remote one.

  • Integrating the capabilities exposed by the Omniverse Service into another application.

  • Executing this Omniverse Service from another technology stack, for which there may not be officially-supported bindings other than HTTP or other Omniverse Service Transports.

  • etc.

 
« Previous section: Exploring the API Next section: Providing unit tests »