Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

When is a WebView ready for a snapshot()?

The JavaFX docs state that a WebView is ready when Worker.State.SUCCEEDED is reached however, unless you wait a while (i.e. Animation, Transition, PauseTransition, etc.), a blank page is rendered.

This suggests that there is an event which occurs inside the WebView readying it for a capture, but what is it?

There's over 7,000 code snippets on GitHub which use SwingFXUtils.fromFXImage but most of them appear to be either unrelated to WebView, are interactive (human masks the race condition) or use arbitrary Transitions (anywhere from 100ms to 2,000ms).

I've tried:

  • Listening on changed(...) from within the WebView's dimensions (height and width properties DoubleProperty implements ObservableValue, which can monitor these things)

    • đźš«Not viable. Sometimes, the value seems to change separate from the paint routine, leading to partial content.
  • Blindly telling anything and everything to runLater(...) on the FX Application Thread.

    • đźš«Many techniques use this, but my own unit tests (as well as some great feedback from other developers) explain that events are often already on the right thread, and this call is redundant. The best I can think of is adds just enough of a delay through queuing that it works for some.
  • Adding a DOM listener/trigger or JavaScript listener/trigger to the WebView

    • đźš«Both JavaScript and the DOM seem to be loaded properly when SUCCEEDED is called despite the blank capture. DOM/JavaScript listeners don't seem to help.
  • Using an Animation or Transition to effectively "sleep" without blocking the main FX thread.

    • ⚠️ This approach works and if the delay is long enough, can yield up to 100% of unit tests, but the Transition times seem to be some future moment that we're just guessing and bad design. For performant or mission-critical applications, this forces the programmer to make a tradeoff between speed or reliability, both a potentially bad experience to the user.

When's a good time to call WebView.snapshot(...)?

Usage:

SnapshotRaceCondition.initialize();
BufferedImage bufferedImage = SnapshotRaceCondition.capture("<html style='background-color: red;'><h1>TEST</h1></html>");
/**
 * Notes:
 * - The color is to observe the otherwise non-obvious cropping that occurs
 *   with some techniques, such as `setPrefWidth`, `autosize`, etc.
 * - Call this function in a loop and then display/write `BufferedImage` to
 *   to see strange behavior on subsequent calls.
 * - Recommended, modify `<h1>TEST</h1` with a counter to see content from
 *   previous captures render much later.
 */

Code Snippet:

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.WritableImage;
import javafx.scene.web.WebView;
import javafx.stage.Stage;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;

public class SnapshotRaceCondition extends Application  {
    private static final Logger log = Logger.getLogger(SnapshotRaceCondition.class.getName());

    // self reference
    private static SnapshotRaceCondition instance = null;

    // concurrent-safe containers for flags/exceptions/image data
    private static AtomicBoolean started  = new AtomicBoolean(false);
    private static AtomicBoolean finished  = new AtomicBoolean(true);
    private static AtomicReference<Throwable> thrown = new AtomicReference<>(null);
    private static AtomicReference<BufferedImage> capture = new AtomicReference<>(null);

    // main javafx objects
    private static WebView webView = null;
    private static Stage stage = null;

    // frequency for checking fx is started
    private static final int STARTUP_TIMEOUT= 10; // seconds
    private static final int STARTUP_SLEEP_INTERVAL = 250; // millis

    // frequency for checking capture has occured 
    private static final int CAPTURE_SLEEP_INTERVAL = 10; // millis

    /** Called by JavaFX thread */
    public SnapshotRaceCondition() {
        instance = this;
    }

    /** Starts JavaFX thread if not already running */
    public static synchronized void initialize() throws IOException {
        if (instance == null) {
            new Thread(() -> Application.launch(SnapshotRaceCondition.class)).start();
        }

        for(int i = 0; i < (STARTUP_TIMEOUT * 1000); i += STARTUP_SLEEP_INTERVAL) {
            if (started.get()) { break; }

            log.fine("Waiting for JavaFX...");
            try { Thread.sleep(STARTUP_SLEEP_INTERVAL); } catch(Exception ignore) {}
        }

        if (!started.get()) {
            throw new IOException("JavaFX did not start");
        }
    }


    @Override
    public void start(Stage primaryStage) {
        started.set(true);
        log.fine("Started JavaFX, creating WebView...");
        stage = primaryStage;
        primaryStage.setScene(new Scene(webView = new WebView()));

        // Add listener for SUCCEEDED
        Worker<Void> worker = webView.getEngine().getLoadWorker();
        worker.stateProperty().addListener(stateListener);

        // Prevents JavaFX from shutting down when hiding window, useful for calling capture(...) in succession
        Platform.setImplicitExit(false);
    }

    /** Listens for a SUCCEEDED state to activate image capture **/
    private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
        if (newState == Worker.State.SUCCEEDED) {
            WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null);

            capture.set(SwingFXUtils.fromFXImage(snapshot, null));
            finished.set(true);
            stage.hide();
        }
    };

    /** Listen for failures **/
    private static ChangeListener<Throwable> exceptListener = new ChangeListener<Throwable>() {
        @Override
        public void changed(ObservableValue<? extends Throwable> obs, Throwable oldExc, Throwable newExc) {
            if (newExc != null) { thrown.set(newExc); }
        }
    };

    /** Loads the specified HTML, triggering stateListener above **/
    public static synchronized BufferedImage capture(final String html) throws Throwable {
        capture.set(null);
        thrown.set(null);
        finished.set(false);

        // run these actions on the JavaFX thread
        Platform.runLater(new Thread(() -> {
            try {
                webView.getEngine().loadContent(html, "text/html");
                stage.show(); // JDK-8087569: will not capture without showing stage
                stage.toBack();
            }
            catch(Throwable t) {
                thrown.set(t);
            }
        }));

        // wait for capture to complete by monitoring our own finished flag
        while(!finished.get() && thrown.get() == null) {
            log.fine("Waiting on capture...");
            try {
                Thread.sleep(CAPTURE_SLEEP_INTERVAL);
            }
            catch(InterruptedException e) {
                log.warning(e.getLocalizedMessage());
            }
        }

        if (thrown.get() != null) {
            throw thrown.get();
        }

        return capture.get();
    }
}

Related:

  • Screenshot of the full web page loaded into JavaFX WebView component, not only visible part
  • Can I capture snapshot of scene programmatically?
  • Whole page screenshot, Java
  • JavaFX 2.0+ WebView /WebEngine render web page to an image
  • Set Height and Width of Stage and Scene in javafx
  • JavaFX:how to resize the stage when using webview
  • Correct sizing of Webview embedded in Tabelcell
  • https://docs.oracle.com/javase/8/javafx/embedded-browser-tutorial/add-browser.htm#CEGDIBBI
  • http://docs.oracle.com/javafx/2/swing/swing-fx-interoperability.htm#CHDIEEJE
  • https://bugs.openjdk.java.net/browse/JDK-8126854
  • https://bugs.openjdk.java.net/browse/JDK-8087569
like image 354
tresf Avatar asked Jan 18 '20 18:01

tresf


2 Answers

To accomodate resizing as well as the underlying snapshot behavior, I (we've) come up with the following working solution. Note, these tests were run 2,000x (Windows, macOS and Linux) providing random WebView sizes with 100% success.

First, I'll quote one of the JavaFX devs. This is quoted from a private (sponsored) bug report:

"I assume you initiate the resizing on the FX AppThread, and that it is done after the SUCCEEDED state is reached. In that case, it seems to me that at that moment, waiting 2 pulses (without blocking the FX AppThread) should give the webkit implementation enough time to make its changes, unless this results in some dimensions being changed in JavaFX, which may result again in dimensions being changed inside webkit.

I'm thinking on how to feed this info into the discussion in JBS, but I'm pretty sure there will be the answer that "you should take a snapshot only when the webcomponent is stable". So in order to anticipate this answer, it would be good to see if this approach work for you. Or, if it turns out to cause other problems, it would be good to think about these problems, and see if/how they can be fixed in OpenJFX itself."

  1. By default, JavaFX 8 uses a default of 600 if height is exactly 0. Code reusing WebView should use setMinHeight(1), setPrefHeight(1) to avoid this problem. This isn't in the code below, but worth mentioning for anyone adapting it to their project.
  2. To accommodate the readiness of WebKit, wait for exactly two pulses from inside an animation timer.
  3. To prevent the snapshot blank bug, leverage the snapshot callback, which also listens for a pulse.
// without this runlater, the first capture is missed and all following captures are offset
Platform.runLater(new Runnable() {
    public void run() {
        // start a new animation timer which waits for exactly two pulses
        new AnimationTimer() {
            int frames = 0;

            @Override
            public void handle(long l) {
                // capture at exactly two frames
                if (++frames == 2) {
                    System.out.println("Attempting image capture");
                    webView.snapshot(new Callback<SnapshotResult,Void>() {
                        @Override
                        public Void call(SnapshotResult snapshotResult) {
                            capture.set(SwingFXUtils.fromFXImage(snapshotResult.getImage(), null));
                            unlatch();
                            return null;
                        }
                    }, null, null);

                    //stop timer after snapshot
                    stop();
                }
            }
        }.start();
    }
});
like image 185
tresf Avatar answered Nov 20 '22 14:11

tresf


It seems this is a bug which occurs when using WebEngine’s loadContent methods. It also occurs when using load to load a local file, but in that case, calling reload() will compensate for it.

Also, since the Stage needs to be showing when you take a snapshot, you need to call show() before loading the content. Since content is loaded asynchronously, it is entirely possible that it will be loaded before the statement following the call to load or loadContent finishes.

The workaround, then, is to place the content in a file, and call the WebEngine’s reload() method exactly once. The second time the content is loaded, a snapshot can be taken successfully from a listener of the load worker’s state property.

Normally, this would be easy:

Path htmlFile = Files.createTempFile("snapshot-", ".html");
Files.writeString(htmlFile, html);

WebEngine engine = myWebView.getEngine();
engine.getLoadWorker().stateProperty().addListener(
    new ChangeListener<Worker.State>() {
        private boolean reloaded;

        @Override
        public void changed(ObservableValue<? extends Worker.State> obs,
                            Worker.State oldState,
                            Worker.State newState) {
            if (reloaded) {
                Image image = myWebView.snapshot(null, null);
                doStuffWithImage(image);

                try {
                    Files.delete(htmlFile);
                } catch (IOException e) {
                    log.log(Level.WARN, "Couldn't delete " + htmlFile, e);
                }
            } else {
                reloaded = true;
                engine.reload();
            }
        }
    });


engine.load(htmlFile.toUri().toString());

But because you are using static for everything, you’ll have to add some fields:

private static boolean reloaded;
private static volatile Path htmlFile;

And you can use them here:

/** Listens for a SUCCEEDED state to activate image capture **/
private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
    if (newState == Worker.State.SUCCEEDED) {
        if (reloaded) {
            WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null);

            capture.set(SwingFXUtils.fromFXImage(snapshot, null));
            finished.set(true);
            stage.hide();

            try {
                Files.delete(htmlFile);
            } catch (IOException e) {
                log.log(Level.WARN, "Couldn't delete " + htmlFile, e);
            }
        } else {
            reloaded = true;
            webView.getEngine().reload();
        }
    }
};

And then you’ll have to reset it each time you load content:

Path htmlFile = Files.createTempFile("snapshot-", ".html");
Files.writeString(htmlFile, html);

Platform.runLater(new Thread(() -> {
    try {
        reloaded = false;
        stage.show(); // JDK-8087569: will not capture without showing stage
        stage.toBack();
        webView.getEngine().load(htmlFile);
    }
    catch(Throwable t) {
        thrown.set(t);
    }
}));

Note that there are better ways to perform multithreaded processing. Instead of using atomic classes, you can simply use volatile fields:

private static volatile boolean started;
private static volatile boolean finished = true;
private static volatile Throwable thrown;
private static volatile BufferedImage capture;

(boolean fields are false by default, and object fields are null by default. Unlike in C programs, this is a hard guarantee made by Java; there is no such thing as uninitialized memory.)

Instead of polling in a loop for changes made in another thread, it’s better to use synchronization, a Lock, or a higher level class like CountDownLatch which uses those things internally:

private static final CountDownLatch initialized = new CountDownLatch(1);
private static volatile CountDownLatch finished;
private static volatile BufferedImage capture;
private static volatile Throwable thrown;
private static boolean reloaded;

private static volatile Path htmlFile;

// main javafx objects
private static WebView webView = null;
private static Stage stage = null;

private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
    if (newState == Worker.State.SUCCEEDED) {
        if (reloaded) {
            WritableImage snapshot = webView.snapshot(null, null);
            capture = SwingFXUtils.fromFXImage(snapshot, null);
            finished.countDown();
            stage.hide();

            try {
                Files.delete(htmlFile);
            } catch (IOException e) {
                log.log(Level.WARNING, "Could not delete " + htmlFile, e);
            }
        } else {
            reloaded = true;
            webView.getEngine().reload();
        }
    }
};

@Override
public void start(Stage primaryStage) {
    log.fine("Started JavaFX, creating WebView...");
    stage = primaryStage;
    primaryStage.setScene(new Scene(webView = new WebView()));

    Worker<Void> worker = webView.getEngine().getLoadWorker();
    worker.stateProperty().addListener(stateListener);

    webView.getEngine().setOnError(e -> {
        thrown = e.getException();
    });

    // Prevents JavaFX from shutting down when hiding window, useful for calling capture(...) in succession
    Platform.setImplicitExit(false);

    initialized.countDown();
}

public static BufferedImage capture(String html)
throws InterruptedException,
       IOException {

    htmlFile = Files.createTempFile("snapshot-", ".html");
    Files.writeString(htmlFile, html);

    if (initialized.getCount() > 0) {
        new Thread(() -> Application.launch(SnapshotRaceCondition2.class)).start();
        initialized.await();
    }

    finished = new CountDownLatch(1);
    thrown = null;

    Platform.runLater(() -> {
        reloaded = false;
        stage.show(); // JDK-8087569: will not capture without showing stage
        stage.toBack();
        webView.getEngine().load(htmlFile.toUri().toString());
    });

    finished.await();

    if (thrown != null) {
        throw new IOException(thrown);
    }

    return capture;
}

reloaded is not declared volatile because it is only accessed in the JavaFX application thread.

like image 24
VGR Avatar answered Nov 20 '22 15:11

VGR