/*
* 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 .
*/
#include "htmlgenerator.h"
#include "Logger.h"
#include "settings.h"
#include
#include
#include
#include
#include
#include
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(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(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++;
}