Package limn.graphics

Class Images

java.lang.Object
limn.graphics.Images

public final class Images extends Object
Image loading and saving facade. Loading is backed by the running backend's single ImageDecoder (installed at startup, mirroring TextRulers/Ui); saving is backed by an ordered list of ImageEncoders, because what an encoder can write is chosen by the caller rather than by the input, so more than one can be useful at once.

Both directions are pure CPU (the GPU texture is created lazily at draw time, and encoding touches no GPU at all), so both are safe to call from the UI thread during setup, and safe to call from a worker thread. Encoding in particular needs no backend, no window and no GL context: ImageFormat.PNG works in a headless test.

Pure CPU is not the same as quick: every entry point here reads, decodes or compresses on the thread that calls it, which is a stall if that thread is the UI thread and a frame is due. decodeAsync(byte[]), encodeAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions), saveAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.nio.file.Path), loadShared(java.nio.file.Path) and fromResourceShared(java.lang.String) do the same work on the Ui worker pool and hand the result back on the UI thread; unlike their synchronous counterparts, those need a running backend. The one blocking call with no asynchronous form is encode(Image, ImageEncodeOptions, OutputStream), which says on itself why it cannot have one.

Two suffixes, and they are not interchangeable. A name ending in Async returns an unstarted Work: nothing happens until start(), and the job can be cancelled. A name ending in Shared returns a CompletableFuture that is already running and is de-duplicated by source, so two callers asking for one file get one picture and one texture, and neither of them may cancel what the other is also waiting for.

To get an image out of the GPU in the first place, see ReadableSurface (an offscreen surface) and GpuRenderer.captureFramebuffer (a window).

  • Field Details

    • DEFAULT_ANIMATION_BUDGET

      public static final long DEFAULT_ANIMATION_BUDGET
      The bytes of frames an animation may have for all of them to be decoded when it is loaded, unless setAnimationBudget(long) says otherwise: 32 MiB, some hundred frames of 320 by 240.
      See Also:
    • DEFAULT_TEXTURE_BUDGET

      public static final long DEFAULT_TEXTURE_BUDGET
      The bytes of image textures a window keeps unless setTextureBudget(long) says otherwise: 256 MiB, counted as the GPU holds them, four bytes a pixel and about a third again for the smaller copies a picture is sampled from when it is drawn below its size. That is some twenty-four pictures of 1920 × 1080. Three quarters of it sits on the heap as well, because a cached texture keeps its Image and the image keeps its pixels.
      See Also:
  • Method Details

    • installDecoder

      public static void installDecoder(ImageDecoder newDecoder)
      Installs the backend decoder (called once at backend startup).
    • uninstallDecoder

      public static void uninstallDecoder(ImageDecoder candidate)
      Uninstalls candidate if it is the installed decoder (backend shutdown).
    • isDecoderInstalled

      public static boolean isDecoderInstalled()
      Returns:
      whether a decoder is installed (i.e. a backend is running)
    • decode

      public static Image decode(byte[] fileBytes)
      Decodes an encoded image (PNG/JPG/GIF/WebP/…) from memory, on the calling thread. An animated GIF, PNG or WebP is an animated image, every frame of it. Cheap for an icon and not for a photograph, whose decode scales with its pixel count and is a dropped frame when the caller is the UI thread. Use decodeAsync(byte[]) outside setup code.

      An SVG is drawn at its own size, one pixel to one of its units, by the installed SVG renderer, and shows only the pictures it embeds: read from a file through SvgIcon.load(java.nio.file.Path), it shows those beside it too, and an SvgIcon is what draws one sharp at any size.

    • setAnimationBudget

      public static void setAnimationBudget(long bytes)
      Moves the size up to which an animated picture is decoded whole. Under it every frame is in memory from the start, and playing costs nothing; over it the picture keeps its file's bytes and decodes a few frames ahead of the one shown, on the worker pool, as it plays, so that a long animation costs its file and a handful of frames and not every frame at once. A frame that is not ready when its time comes is not waited for: the one before it stays. It applies to pictures decoded from now on. Any thread.
      Parameters:
      bytes - the most bytes of frames, four a pixel, decoded at once; 0 decodes every animation as it plays
      Throws:
      IllegalArgumentException - if bytes is negative
    • animationBudget

      public static long animationBudget()
      Returns:
      the size up to which an animated picture is decoded whole; see setAnimationBudget(long)
    • animation

      public static Image animation(List<Image> frames, int[] millis, int loopCount)
      Makes an animated image of frames from anywhere. Its own pixels are the first frame's, so whatever draws it without playing it draws that frame.
      Parameters:
      frames - the frames in order, each of the first one's size; at least one
      millis - how long each is shown, in milliseconds, one for each frame, each above 0
      loopCount - how many turns it asks for, 0 for one that does not end
      Returns:
      the image; with one frame, a still image, that frame
      Throws:
      IllegalArgumentException - if there is no frame, the sizes differ, a time is not above 0, the times are not one a frame, or a frame is itself animated
    • fromSheet

      public static Image fromSheet(Image sheet, int columns, int rows, int frameMillis)
      Makes an animated image of a sheet of frames laid out in a grid, read along each row from the top: the frames of a sprite, drawn side by side in one picture. Each frame becomes an image of its own, and the animation turns without end.
      Parameters:
      sheet - the picture holding the frames
      columns - how many frames across
      rows - how many down
      frameMillis - how long each frame is shown, in milliseconds
      Returns:
      the animation
      Throws:
      IllegalArgumentException - if the grid does not divide the sheet evenly, or a number is not above 0
    • fromSheet

      public static Image fromSheet(Image sheet, int columns, int rows, int frames, int frameMillis)
      As fromSheet(Image, int, int, int), for a sheet whose last row is not full.
      Parameters:
      frames - how many frames the sheet holds, from 1 to columns * rows
    • load

      public static Image load(Path file)
      Reads and decodes an image file on the calling thread: a blocking read followed by a decode, so its cost is the disk's as well as the decoder's and is unbounded on a network volume. Use loadShared(java.nio.file.Path) outside setup code.
    • fromResource

      public static Image fromResource(String resource)
      Reads and decodes a classpath resource on the calling thread; see fromResourceShared(java.lang.String) for the form that does it on the worker pool.
    • installEncoder

      public static void installEncoder(ImageEncoder encoder)
      Installs encoder at the end of the probe order. Encoders are asked in the order they were installed, and PngEncoder is already installed when this class loads, so an application that means to replace PNG rather than add a format must uninstallEncoder(limn.graphics.ImageEncoder) PngEncoder.INSTANCE first, or its own encoder is never reached for ImageFormat.PNG. Installing an already installed encoder is a no-op that leaves the order untouched, so running backend startup twice cannot reshuffle priorities.
      Throws:
      NullPointerException - if encoder is null
    • uninstallEncoder

      public static void uninstallEncoder(ImageEncoder encoder)
      Removes encoder. No-op when it was never installed, and a no-op for null rather than a failure: the asymmetry with installEncoder(limn.graphics.ImageEncoder) is deliberate, so a cleanup block can uninstall whatever it may or may not have installed without a null check of its own.
    • uninstallAllEncoders

      public static void uninstallAllEncoders()
      Removes every encoder, the built-in PNG one included. After this, encoding anything fails until something is installed. For a test that wants to observe the empty case; restore the default with installEncoder(PngEncoder.INSTANCE) and installEncoder(GifEncoder.INSTANCE).
    • installedEncoders

      public static List<ImageEncoder> installedEncoders()
      Returns:
      an immutable snapshot of the installed encoders, in the order they are asked
    • canEncode

      public static boolean canEncode(ImageEncodeOptions options)
      Returns:
      whether some installed encoder claims options, the non-throwing form of encode(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.io.OutputStream), for disabling a menu entry or choosing another format. A true here does not promise that the encode succeeds.
      Throws:
      NullPointerException - if options is null
    • canEncode

      public static boolean canEncode(ImageFormat format)
      Returns:
      whether format can be written at ImageEncodeOptions.DEFAULT_QUALITY.
    • encode

      public static void encode(Image image, ImageEncodeOptions options, OutputStream out) throws IOException
      Encodes image into out with the first installed encoder that claims options. The stream is neither flushed nor closed: the caller owns it.

      If that encoder then fails, the failure propagates and no later encoder is tried: the one that accepted the request is the one that knows what is wrong with it, and replacing that with a generic message would be the worst diagnostic available.

      The whole compress-and-write runs on the calling thread, and there is deliberately no asynchronous form of this overload: out belongs to the caller, and only the caller knows whether writing to it from a worker thread is safe. Wrap this call in Ui.work with a stream you are willing to hand over, or use encodeAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions) (bytes) or saveAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.nio.file.Path) (a file), both of which own their sink.

      Throws:
      IOException - if out fails
      UnsupportedOperationException - if no installed encoder claims options; the message names every encoder asked, in order
      NullPointerException - if any argument is null
    • encode

      public static byte[] encode(Image image, ImageEncodeOptions options)
      Encodes image and returns the file bytes. Convenient, and the whole file is in memory; prefer encode(Image, ImageEncodeOptions, OutputStream) or save(Image, ImageEncodeOptions, Path) for anything large.

      Runs the whole compression on the calling thread. At framebuffer sizes that is long enough to be a visible hitch inside a frame, so on the UI thread use encodeAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions).

      Throws:
      UnsupportedOperationException - if no installed encoder claims options
      UncheckedIOException - never in practice: the sink is a byte array
    • encode

      public static byte[] encode(Image image, ImageFormat format)
      Encodes image as format at ImageEncodeOptions.DEFAULT_QUALITY.
    • save

      public static void save(Image image, ImageEncodeOptions options, Path file)
      Encodes image and writes it to file, replacing whatever was there and creating the parent directories if they are missing. Encode and write both happen on the calling thread; see saveAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.nio.file.Path) for the form that moves both to the worker pool.
      Throws:
      UnsupportedOperationException - if no installed encoder claims options
      UncheckedIOException - if the file cannot be written
    • save

      public static void save(Image image, ImageFormat format, Path file)
      Saves image as format at ImageEncodeOptions.DEFAULT_QUALITY.

      The format is a parameter and is never inferred from the file name. A suffix is a hint that anyone can get wrong, and inferring from it turns a mistyped name into a file whose contents disagree with its extension, which nothing downstream can detect.

    • saveAsync

      public static Work<Path> saveAsync(Image image, ImageEncodeOptions options, Path file)
      Encodes and writes file on the Ui worker pool and hands file back to onSuccess on the UI thread. Requires a running backend, unlike the synchronous form.

      Returned unstarted, like everything else here whose name ends in Async: attach the handlers, then start(). Dropping it writes nothing at all.

      This is where the work belongs when the image came from a readback: the read itself is a GPU operation and cannot leave the UI thread, but the encode that follows it is plain CPU work on a finished Image, and at framebuffer sizes it is long enough to be a visible hitch if it runs inside a frame.

      The result is a path with nothing to release, so no onDiscarded is attached; a cancelled save may still have written the file, because cancelling stops the delivery and not the body.

      Throws:
      NullPointerException - if any argument is null
      IllegalStateException - if no backend is running
    • encodeAsync

      public static Work<byte[]> encodeAsync(Image image, ImageEncodeOptions options)
      Compresses image on the Ui worker pool and hands the file bytes to onSuccess on the UI thread, the in-memory counterpart of saveAsync(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.nio.file.Path), for the caller who wants the bytes rather than a file: an upload, a diff against a reference. Requires a running backend, unlike the synchronous encode(limn.graphics.Image, limn.graphics.ImageEncodeOptions, java.io.OutputStream).

      Returned unstarted, so the caller can attach handlers first:

      
       Images.encodeAsync(shot, new ImageEncodeOptions(ImageFormat.PNG))
             .onSuccess(bytes -> upload.send(bytes))
             .onFailure(error -> status.setText(error.getMessage()))
             .deliverIf(this::isShowing)
             .start();
       

      Every failure arrives at onFailure on the UI thread, including "no installed encoder claims these options": the encoder is chosen when the body runs, not when this returns, so a format nothing can write is a delivered failure rather than a throw at the call site. The result is a plain array with nothing to release, so no onDiscarded is attached and a result nobody takes is simply garbage.

      No progress is reported: an encoder is one call with no interior to report from. And cancelling after the body has begun does not stop the compression (it only prevents the delivery) because the pool is never interrupted.

      Parameters:
      image - the picture to compress; read but not retained
      options - format and quality, as for encode(Image, ImageEncodeOptions)
      Throws:
      NullPointerException - if any argument is null
      IllegalStateException - if no backend is running
    • loadShared

      public static CompletableFuture<Image> loadShared(Path file)
      Reads and decodes file on the Ui worker pool; the returned future is already running and completes on the UI thread, so loadShared(file).thenAccept(view::setImage) sets the image where widget mutation is legal. Requires a running backend (a Ui runtime and an installed decoder), unlike the synchronous load(java.nio.file.Path).

      Shared, which is why this is not a Work and not called loadAsync. Loads are de-duplicated by absolute path: concurrent and later calls for the same path return the same future and therefore the same Image instance, which is what makes one texture serve every user of that file; the backend's texture cache keys by instance identity, so decoding the same file twice would upload it twice. A result with two consumers cannot also be a job either of them may cancel, so there is nothing to start and nothing to withdraw here. A failed load is evicted, so a later call retries rather than replaying the failure forever, and clearSharedCache() forces a re-read after the file changed on disk.

      A picture that landed is kept, so that the next ask costs nothing, within a budget: setSharedBudget(long). Past it the one asked for longest ago is let go, and read again only if it is asked for when nobody holds it any more. loadPinned(java.nio.file.Path) keeps one whatever the budget.

      The failure reaches observers through the future, on the UI thread (exceptionally, whenComplete); nothing is thrown from this method beyond an argument check.

    • loadPinned

      public static CompletableFuture<Image> loadPinned(Path file)
      Loads file as loadShared(java.nio.file.Path) does and keeps the result whatever the budget: it is never let go and counts nothing toward it, until unpin(Path). For a picture an application wants at hand for as long as it runs, at the price of the memory it takes for that long. A path already loaded and shared is pinned from this call on, and is the same object.

      Pins are counted: each call is taken back by one unpin(Path), so two parts of an application that each pin a file keep it pinned until both have unpinned it.

      Parameters:
      file - the file to read
      Returns:
      the shared future, already running or already complete, completing on the UI thread
    • unpin

      public static void unpin(Path file)
      Takes back one loadPinned(java.nio.file.Path) of a file; when every one is taken back, the file is back among the shared loads the budget keeps or lets go. Nothing happens for a file that is not pinned. Any thread.
      Parameters:
      file - the file a load was pinned by
    • fromResourceShared

      public static CompletableFuture<Image> fromResourceShared(String resource)
      Reads and decodes a classpath resource on the Ui worker pool; the returned future is already running and completes on the UI thread. De-duplicated by resource name, retried after a failure and failing through the future exactly as loadShared(java.nio.file.Path) describes, and likewise requiring a running backend.
    • fromResourcePinned

      public static CompletableFuture<Image> fromResourcePinned(String resource)
      Loads a classpath resource as fromResourceShared(java.lang.String) does and keeps the result whatever the budget, until as many unpinResource(java.lang.String)s as calls; see loadPinned(java.nio.file.Path).
      Parameters:
      resource - the resource's name
      Returns:
      the shared future, already running or already complete, completing on the UI thread
    • unpinResource

      public static void unpinResource(String resource)
      Takes back one fromResourcePinned(java.lang.String) of a resource; when every one is taken back, the resource is back among the shared loads the budget keeps or lets go. Nothing happens for a resource that is not pinned. Any thread.
      Parameters:
      resource - the name a load was pinned by
    • setSharedBudget

      public static void setSharedBudget(long bytes)
      Moves the budget the shared loads are kept within. A load that landed is kept, so that the next ask for it costs nothing, for as long as what is kept fits; past the budget the one asked for longest ago is let go first, every file before any resource, since an application's own resources are few and it comes back to them. A pinned load is never let go and counts nothing.

      What is let go is not lost to whoever holds it: a picture still shown or held is the same object, and an ask for its source hands that object back without reading anything. Only one that nobody holds is read again, when asked for.

      The budget starts at one part in 4 of the most heap the process may use. Raise it for an application that keeps coming back to more than that, or pin what must never be read twice; lower it where memory is short. Any thread.

      Parameters:
      bytes - the most bytes of unpinned shared loads kept, as they sit on the heap; 0 keeps none, and Long.MAX_VALUE lets none go
      Throws:
      IllegalArgumentException - if bytes is negative
    • sharedBudget

      public static long sharedBudget()
      The budget the shared loads are kept within; see setSharedBudget(long).
      Returns:
      the current budget, in bytes
    • decodeAsync

      public static Work<Image> decodeAsync(byte[] fileBytes)
      Decodes in-memory bytes on the Ui worker pool and hands the picture to onSuccess on the UI thread, carrying the decoder's failure to onFailure when it throws. Requires a running backend.

      Returned unstarted: attach the handlers, then start().

      Uncached, unlike loadShared(java.nio.file.Path): bytes have no stable name to de-duplicate by, so two calls with the same array decode twice and produce two Image instances, and so two GPU textures. Hold the result rather than re-decoding. Being unshared is exactly what lets this one be a cancellable job.

      The caller keeps ownership of fileBytes and must not modify the array until the job has delivered: the decode reads it from a worker thread.

      Throws:
      IllegalStateException - if no backend is running
    • clearSharedCache

      public static void clearSharedCache()
      Drops every shared load, so the next loadShared(java.nio.file.Path) or fromResourceShared(java.lang.String) of a source reads it again (e.g. after files changed on disk). Image instances already handed out are unaffected and keep their textures; only the mapping from source to future is cleared, a pinned one's too, which is pinned again by loading it so. Any thread.
    • setTextureBudget

      public static void setTextureBudget(long bytes)
      Moves the budget each window keeps its image textures within, from that window's next frame on.

      A texture is made when an image is first drawn, and each window caches its own. A window drops one at a frame boundary once 300 frames have gone by without drawing it, and sooner, least recently drawn first, while what it holds is over this budget. A texture the frame just finished drew is never dropped, so the budget is soft: a single picture larger than it still shows, and stays for as long as every frame draws it, and a screen whose pictures together exceed it keeps them all while each frame draws them all. A frame that repaints only part of a window draws only the pictures in that part, so over the budget a picture elsewhere can be dropped while it is still on screen. Drawing an image whose texture was dropped makes it again, at the cost of an upload in that frame.

      Raise it for an application that keeps coming back to a set of large pictures bigger than the default, at the cost in memory the default describes; lower it where memory is short. The budget is per window, so two windows full of pictures may hold twice it. A window that is not drawing renders no frames and drops nothing until it draws again. Any thread.

      Parameters:
      bytes - the most texture bytes, mipmaps included, a window keeps unless its last frame drew more; 0 keeps only what that frame drew
      Throws:
      IllegalArgumentException - if bytes is negative
    • textureBudget

      public static long textureBudget()
      The budget each window keeps its image textures within: DEFAULT_TEXTURE_BUDGET until setTextureBudget(long) moves it.
      Returns:
      the current budget, in bytes