417 lines
13 KiB
C++
417 lines
13 KiB
C++
/*
|
|
* Copyright (c) 2025 Meltytech, LLC
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "htmlgenerator.h"
|
|
#include "Logger.h"
|
|
#include "settings.h"
|
|
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QNetworkReply>
|
|
#include <QUrl>
|
|
|
|
HtmlGenerator::HtmlGenerator(QObject *parent)
|
|
: QObject(parent)
|
|
, m_webSocket(new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this))
|
|
, m_networkManager(new QNetworkAccessManager(this))
|
|
, m_messageId(1)
|
|
, m_chromeProcess(new QProcess(this))
|
|
{
|
|
connect(m_webSocket, &QWebSocket::connected, this, &HtmlGenerator::onWebSocketConnected);
|
|
connect(m_webSocket, &QWebSocket::textMessageReceived, this, &HtmlGenerator::onMessageReceived);
|
|
connect(m_webSocket, &QWebSocket::disconnected, this, &HtmlGenerator::onWebSocketDisconnected);
|
|
}
|
|
|
|
HtmlGenerator::~HtmlGenerator()
|
|
{
|
|
if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running) {
|
|
m_chromeProcess->terminate();
|
|
m_chromeProcess->waitForFinished(2000);
|
|
m_chromeProcess->kill();
|
|
}
|
|
}
|
|
|
|
void HtmlGenerator::setAnimationParameters(double fps, int duration)
|
|
{
|
|
m_fps = fps;
|
|
m_duration = duration;
|
|
m_animationMode = (fps > 0 && duration > 0);
|
|
}
|
|
|
|
void HtmlGenerator::launchBrowser(const QString &executablePath,
|
|
const QString &url,
|
|
const QSize &viewport,
|
|
const QString &outputPath)
|
|
{
|
|
m_url = url;
|
|
m_viewport = viewport;
|
|
m_outputPath = outputPath;
|
|
|
|
// Start browser with appropriate arguments
|
|
QStringList arguments;
|
|
arguments << "--remote-debugging-port=9222"
|
|
<< "--headless=new"
|
|
<< "--disable-gpu"
|
|
<< "--no-sandbox"
|
|
<< "--no-zygote"
|
|
// << "--single-process"
|
|
<< "--no-first-run"
|
|
<< "--no-default-browser-check"
|
|
<< "--allow-file-access-from-files"
|
|
<< "--user-data-dir=" + m_tempDir.path();
|
|
|
|
connect(m_chromeProcess, &QProcess::finished, this, &HtmlGenerator::onChromeProcessFinished);
|
|
connect(m_chromeProcess, &QProcess::errorOccurred, this, &HtmlGenerator::onChromeProcessError);
|
|
|
|
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
|
|
auto env = QProcessEnvironment::systemEnvironment();
|
|
env.remove("LD_LIBRARY_PATH");
|
|
LOG_DEBUG() << env.toStringList();
|
|
m_chromeProcess->setProcessEnvironment(env);
|
|
#endif
|
|
|
|
LOG_DEBUG() << executablePath + " " + arguments.join(' ');
|
|
m_chromeProcess->start(executablePath, arguments);
|
|
|
|
if (!m_chromeProcess->waitForStarted(5000)) {
|
|
LOG_ERROR() << "Failed to start browser process";
|
|
Settings.setChromiumPath(QString());
|
|
return;
|
|
}
|
|
|
|
// Wait a bit for browser to start up
|
|
QTimer::singleShot(2000, this, &HtmlGenerator::connectToBrowser);
|
|
}
|
|
|
|
void HtmlGenerator::connectToBrowser()
|
|
{
|
|
// Get the list of pages from Chrome
|
|
QNetworkRequest request(QUrl("http://localhost:9222/json/list"));
|
|
QNetworkReply *reply = m_networkManager->get(request);
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
LOG_ERROR() << "Failed to get Chrome debug info:" << reply->errorString();
|
|
Settings.setChromiumPath(QString());
|
|
return;
|
|
}
|
|
|
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
|
QJsonArray pages = doc.array();
|
|
|
|
QString webSocketUrl;
|
|
// Look for an existing page or create a new one
|
|
for (auto pageValue : std::as_const(pages)) {
|
|
const auto page = pageValue.toObject();
|
|
if (page["type"].toString() == "page") {
|
|
webSocketUrl = page["webSocketDebuggerUrl"].toString();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (webSocketUrl.isEmpty()) {
|
|
// Create a new page
|
|
LOG_DEBUG() << "No existing page found, creating a new one";
|
|
createNewPage();
|
|
} else {
|
|
LOG_DEBUG() << "Using existing page, connecting to WebSocket:" << webSocketUrl;
|
|
m_webSocket->open(QUrl(webSocketUrl));
|
|
}
|
|
reply->deleteLater();
|
|
});
|
|
}
|
|
|
|
void HtmlGenerator::createNewPage()
|
|
{
|
|
QNetworkRequest request(QUrl("http://localhost:9222/json/new"));
|
|
auto *reply = m_networkManager->get(request);
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
LOG_ERROR() << "Failed to create new page:" << reply->errorString();
|
|
return;
|
|
}
|
|
|
|
const auto doc = QJsonDocument::fromJson(reply->readAll());
|
|
const auto page = doc.object();
|
|
const auto webSocketUrl = page["webSocketDebuggerUrl"].toString();
|
|
|
|
if (webSocketUrl.isEmpty()) {
|
|
LOG_ERROR() << "No WebSocket URL found in new page response";
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG() << "Created new page, connecting to WebSocket:" << webSocketUrl;
|
|
m_webSocket->open(QUrl(webSocketUrl));
|
|
reply->deleteLater();
|
|
});
|
|
}
|
|
|
|
void HtmlGenerator::onWebSocketConnected()
|
|
{
|
|
LOG_DEBUG() << "Connected to Chrome DevTools";
|
|
|
|
// Enable Runtime and Page events
|
|
sendCommand("Runtime.enable");
|
|
sendCommand("Page.enable");
|
|
|
|
// Set default background color to transparent
|
|
QJsonObject params;
|
|
params["color"] = QJsonObject{{"r", 0}, {"g", 0}, {"b", 0}, {"a", 0}};
|
|
sendCommand("Emulation.setDefaultBackgroundColorOverride", params);
|
|
|
|
// Set viewport
|
|
params = QJsonObject();
|
|
params["width"] = m_viewport.width();
|
|
params["height"] = m_viewport.height();
|
|
params["deviceScaleFactor"] = 1;
|
|
params["mobile"] = false;
|
|
sendCommand("Emulation.setDeviceMetricsOverride", params);
|
|
|
|
// Navigate to the URL
|
|
QJsonObject navParams;
|
|
navParams["url"] = m_url;
|
|
sendCommand("Page.navigate", navParams);
|
|
|
|
// Set a timeout for taking screenshot in case page load event doesn't fire
|
|
QTimer::singleShot(10000, this, &HtmlGenerator::takeScreenshot);
|
|
}
|
|
|
|
void HtmlGenerator::onMessageReceived(const QString &message)
|
|
{
|
|
// if (!message.contains("\"data\""))
|
|
// LOG_DEBUG() << "Received message:" << message;
|
|
|
|
const auto doc = QJsonDocument::fromJson(message.toUtf8());
|
|
const auto obj = doc.object();
|
|
|
|
if (obj.contains("method")) {
|
|
const auto method = obj["method"].toString();
|
|
if (method == "Page.loadEventFired") {
|
|
LOG_DEBUG() << "Page loaded, starting capture";
|
|
if (m_animationMode) {
|
|
startAnimationCapture();
|
|
} else {
|
|
takeScreenshot();
|
|
}
|
|
}
|
|
} else if (obj.contains("id") && obj.contains("result")) {
|
|
const auto id = obj["id"].toInt();
|
|
if (m_pendingScreenshot && id == m_screenshotMessageId) {
|
|
if (m_animationMode) {
|
|
handleAnimationFrame(obj["result"].toObject());
|
|
} else {
|
|
handleScreenshotResult(obj["result"].toObject());
|
|
}
|
|
}
|
|
} else if (obj.contains("error")) {
|
|
LOG_ERROR() << "Error received:" << QJsonDocument(obj["error"].toObject()).toJson();
|
|
}
|
|
}
|
|
|
|
void HtmlGenerator::onWebSocketDisconnected()
|
|
{
|
|
LOG_DEBUG() << "Disconnected from Chrome DevTools";
|
|
}
|
|
|
|
void HtmlGenerator::onChromeProcessFinished(int exitCode, QProcess::ExitStatus exitStatus)
|
|
{
|
|
// Q_UNUSED(exitCode)
|
|
Q_UNUSED(exitStatus)
|
|
LOG_DEBUG() << "Chrome process finished" << exitCode;
|
|
}
|
|
|
|
void HtmlGenerator::onChromeProcessError(QProcess::ProcessError error)
|
|
{
|
|
// Ignore crash errors after successful screenshot completion
|
|
if (error == QProcess::Crashed && m_screenshotCompleted) {
|
|
LOG_DEBUG() << "Chrome process crashed after successful screenshot completion (expected)";
|
|
return;
|
|
}
|
|
|
|
LOG_ERROR() << "Chrome process error:" << error;
|
|
}
|
|
|
|
void HtmlGenerator::startAnimationCapture()
|
|
{
|
|
// Create output directory
|
|
const QDir outputDir(m_outputPath);
|
|
if (!outputDir.exists()) {
|
|
if (!outputDir.mkpath(".")) {
|
|
LOG_ERROR() << "Failed to create output directory:" << m_outputPath;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Calculate animation parameters
|
|
const auto frameInterval = 1000.0 / m_fps; // milliseconds per frame
|
|
m_totalFrames = static_cast<int>(std::ceil(m_duration / frameInterval));
|
|
m_currentFrame = 0;
|
|
|
|
LOG_DEBUG() << QString("Capturing %1 frames at %2 fps over %3ms...")
|
|
.arg(m_totalFrames)
|
|
.arg(m_fps)
|
|
.arg(m_duration);
|
|
|
|
// Start timing
|
|
m_animationElapsed.start();
|
|
|
|
// Take first frame
|
|
captureAnimationFrame();
|
|
}
|
|
|
|
void HtmlGenerator::captureAnimationFrame()
|
|
{
|
|
if (m_currentFrame >= m_totalFrames) {
|
|
completeAnimationCapture();
|
|
return;
|
|
}
|
|
|
|
// LOG_DEBUG() << "Taking screenshot...";
|
|
m_pendingScreenshot = true;
|
|
|
|
QJsonObject params;
|
|
params["format"] = "png";
|
|
params["omitBackground"] = true;
|
|
params["captureBeyondViewport"] = false;
|
|
m_screenshotMessageId = sendCommand("Page.captureScreenshot", params);
|
|
}
|
|
|
|
void HtmlGenerator::handleAnimationFrame(const QJsonObject &result)
|
|
{
|
|
m_pendingScreenshot = false;
|
|
|
|
const auto base64Data = result["data"].toString();
|
|
const auto imageData = QByteArray::fromBase64(base64Data.toUtf8());
|
|
|
|
// Save frame with zero-padded filename
|
|
const auto frameNumber = QString("%1").arg(m_currentFrame, 4, 10, QLatin1Char('0'));
|
|
const auto filename = QDir(m_outputPath).filePath(QString("frame_%1.png").arg(frameNumber));
|
|
|
|
QFile file(filename);
|
|
if (file.open(QIODevice::WriteOnly)) {
|
|
file.write(imageData);
|
|
file.close();
|
|
LOG_DEBUG() << "Captured frame" << (m_currentFrame + 1) << "/" << m_totalFrames;
|
|
emit progressUpdate(float(m_currentFrame + 1) / m_totalFrames);
|
|
} else {
|
|
LOG_ERROR() << "Failed to save frame:" << file.errorString();
|
|
return;
|
|
}
|
|
|
|
m_currentFrame++;
|
|
|
|
// Schedule next frame
|
|
if (m_currentFrame < m_totalFrames) {
|
|
// Calculate when the next frame should be captured
|
|
const auto frameInterval = 1000.0 / m_fps;
|
|
const auto targetTime = static_cast<qint64>(m_currentFrame * frameInterval);
|
|
const auto currentTime = m_animationElapsed.elapsed();
|
|
|
|
int delay = std::max(0LL, targetTime - currentTime);
|
|
if (delay == 0)
|
|
LOG_DEBUG() << "frame duration" << frameInterval << "delay" << targetTime - currentTime
|
|
<< "ms";
|
|
QTimer::singleShot(delay, this, &HtmlGenerator::captureAnimationFrame);
|
|
} else {
|
|
completeAnimationCapture();
|
|
}
|
|
}
|
|
|
|
void HtmlGenerator::completeAnimationCapture()
|
|
{
|
|
m_screenshotCompleted = true;
|
|
|
|
LOG_DEBUG() << QString("Animation frames saved to %1/").arg(m_outputPath);
|
|
|
|
// Close the browser and exit
|
|
m_webSocket->close();
|
|
if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running) {
|
|
m_chromeProcess->terminate();
|
|
}
|
|
|
|
// Emit signal indicating animation frames are ready
|
|
emit imageReady(m_outputPath);
|
|
}
|
|
|
|
void HtmlGenerator::takeScreenshot()
|
|
{
|
|
if (m_pendingScreenshot) {
|
|
LOG_DEBUG() << "Screenshot already pending, skipping";
|
|
return;
|
|
}
|
|
|
|
LOG_DEBUG() << "Taking screenshot...";
|
|
m_pendingScreenshot = true;
|
|
|
|
QJsonObject params;
|
|
params["format"] = "png";
|
|
params["omitBackground"] = true;
|
|
|
|
m_screenshotMessageId = sendCommand("Page.captureScreenshot", params);
|
|
}
|
|
|
|
void HtmlGenerator::handleScreenshotResult(const QJsonObject &result)
|
|
{
|
|
m_pendingScreenshot = false;
|
|
m_screenshotCompleted = true;
|
|
|
|
const auto base64Data = result["data"].toString();
|
|
const auto imageData = QByteArray::fromBase64(base64Data.toUtf8());
|
|
bool success = false;
|
|
|
|
QFile file(m_outputPath);
|
|
if (file.open(QIODevice::WriteOnly)) {
|
|
file.write(imageData);
|
|
file.close();
|
|
success = true;
|
|
LOG_DEBUG() << "Screenshot saved to:" << m_outputPath;
|
|
} else {
|
|
LOG_ERROR() << "Failed to save screenshot:" << file.errorString();
|
|
}
|
|
|
|
// Close the browser and exit
|
|
sendCommand("Browser.close");
|
|
QTimer::singleShot(100, m_chromeProcess, [=]() {
|
|
m_webSocket->close();
|
|
if (m_chromeProcess && m_chromeProcess->state() == QProcess::Running)
|
|
m_chromeProcess->terminate();
|
|
});
|
|
if (success)
|
|
emit imageReady(m_outputPath);
|
|
}
|
|
|
|
int HtmlGenerator::sendCommand(const QString &method, const QJsonObject ¶ms)
|
|
{
|
|
QJsonObject command;
|
|
command["id"] = m_messageId;
|
|
command["method"] = method;
|
|
if (!params.isEmpty()) {
|
|
command["params"] = params;
|
|
}
|
|
|
|
const QJsonDocument doc(command);
|
|
const QString message = doc.toJson(QJsonDocument::Compact);
|
|
|
|
// LOG_DEBUG() << "Sending command:" << message;
|
|
m_webSocket->sendTextMessage(message);
|
|
|
|
return m_messageId++;
|
|
}
|